Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhook Logging That Survives a Compliance Audit

Audit-grade webhook logging means storing every event you receive with a timestamp, preserving the original payload, redacting sensitive fields (card numbers, CVV), making log entries immutable (no updates or deletes), retaining records for the required period (typically 1 to 7 years depending on jurisdiction), and being able to produce a complete event history for any transaction on demand.

Why Audit Logging Matters for Payment Webhooks

Payment processing is regulated. If you accept payments through Paystack, you are handling financial transactions, and various parties may ask you to prove what happened:

  • Customers: "I paid but did not receive my order." You need to show whether you received the webhook and what your system did with it.
  • Paystack: During a dispute or chargeback investigation, Paystack may ask for your records of the transaction.
  • Regulators: The Central Bank of Nigeria, the Central Bank of Kenya, and other African financial regulators require licensed entities to maintain transaction records for specified periods.
  • Auditors: If your company undergoes a financial audit, PCI DSS assessment, or SOC 2 examination, auditors will ask to see your payment processing logs.
  • Your own team: When debugging a production issue six months later, you need to see exactly what happened.

The common thread: you need a reliable, complete, tamper-evident record of every payment event your system received and how it was processed. Regular application logs (console.log) are not sufficient. They get rotated, overwritten, lost during deployments, and mixed with thousands of unrelated log lines.

This article is part of the Paystack webhooks engineering guide. For the separate topic of storing raw payloads, see storing raw webhook payloads for audit.

What to Log for Every Webhook Event

Each audit log entry should contain these fields:

CREATE TABLE webhook_audit_log (
  id BIGSERIAL PRIMARY KEY,
  event_id UUID NOT NULL DEFAULT gen_random_uuid(),
  event_type VARCHAR(100) NOT NULL,
  reference VARCHAR(255),
  amount_minor INTEGER,
  currency VARCHAR(3),
  customer_email VARCHAR(255),
  customer_code VARCHAR(100),
  domain VARCHAR(10), -- 'test' or 'live'
  raw_payload JSONB NOT NULL,
  signature_header VARCHAR(200) NOT NULL,
  signature_valid BOOLEAN NOT NULL,
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  source_ip INET,
  processing_outcome VARCHAR(30) NOT NULL,
  -- received, processed, duplicate_skipped, failed, unknown_event
  error_message TEXT,
  server_version VARCHAR(50) -- app version that processed this event
);

-- Indexes for common audit queries
CREATE INDEX idx_audit_reference ON webhook_audit_log(reference);
CREATE INDEX idx_audit_received ON webhook_audit_log(received_at);
CREATE INDEX idx_audit_event_type ON webhook_audit_log(event_type);
CREATE INDEX idx_audit_customer ON webhook_audit_log(customer_email);
CREATE INDEX idx_audit_outcome ON webhook_audit_log(processing_outcome);

Key design decisions:

  • event_id (UUID): A unique identifier for this log entry, independent of Paystack's IDs. Useful for referencing specific log entries in incident reports.
  • raw_payload (JSONB): The complete webhook payload as received (after redaction). JSONB allows querying into the payload with PostgreSQL JSON operators.
  • signature_valid (BOOLEAN): Records whether the signature check passed. Failed signature events are logged too, because they might indicate an attack or a configuration issue.
  • processing_outcome: What your system did with this event. Not just "received" but the final outcome: processed, skipped as duplicate, failed, or ignored as unknown.
  • server_version: Which version of your code processed this event. Invaluable when debugging issues that started after a specific deployment.

Log every event, even ones you do not process. If you receive a some.future.event that your handler does not recognize, log it as unknown_event. An auditor might ask about it later, and you want to show that you received it and made a deliberate choice not to process it.

Redacting Sensitive Data

Paystack masks most sensitive data before sending it in webhooks. Card numbers appear as "first_six": "408408", "last_four": "4081", not as full PANs. Bank account numbers are partially masked. However, you should verify this and add your own redaction layer as a safety net.

Fields to redact or verify are masked:

  • Card PAN: Should never appear in full. Paystack sends first six and last four digits separately. If you somehow receive a full card number, redact it immediately.
  • CVV: Should never appear in any payload. If it does, redact and alert your security team.
  • Full bank account numbers: For transfer recipients, Paystack may send account numbers. Mask all but the last four digits.
  • Authorization codes: These can be used to charge a customer again. Redact from audit logs unless you specifically need them for reauthorization.
  • Customer personal data: Email addresses are needed for audit lookups, but phone numbers and addresses should be evaluated under your data protection policy.
function redactPayload(payload) {
  // Deep clone to avoid modifying the original
  const redacted = JSON.parse(JSON.stringify(payload));

  if (redacted.data) {
    // Redact authorization details
    if (redacted.data.authorization) {
      const auth = redacted.data.authorization;
      // Keep first_six and last_four (already masked by Paystack)
      // Redact the authorization_code
      if (auth.authorization_code) {
        auth.authorization_code = '[REDACTED]';
      }
      // Redact bin (first 6 digits, same as first_six but sometimes present separately)
      if (auth.bin) {
        auth.bin = '[REDACTED]';
      }
    }

    // Redact recipient account numbers for transfers
    if (redacted.data.recipient && redacted.data.recipient.details) {
      const details = redacted.data.recipient.details;
      if (details.account_number && details.account_number.length > 4) {
        const lastFour = details.account_number.slice(-4);
        details.account_number = '******' + lastFour;
      }
    }

    // Redact any field that looks like a full card number (16 digits)
    redactDeep(redacted, function(key, value) {
      if (typeof value === 'string' && /^d{13,19}$/.test(value)) {
        return '******' + value.slice(-4);
      }
      return value;
    });
  }

  return redacted;
}

function redactDeep(obj, redactor) {
  for (var key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      if (typeof obj[key] === 'object' && obj[key] !== null) {
        redactDeep(obj[key], redactor);
      } else {
        obj[key] = redactor(key, obj[key]);
      }
    }
  }
}

Run the redaction function before storing the payload in the audit log. The original (unredacted) raw payload should only exist in memory for the duration of signature verification and then be discarded. If you also store raw payloads separately (see storing raw payloads), apply redaction there too.

Making Audit Logs Immutable

An audit log that can be edited or deleted is not trustworthy. If someone (an engineer, a compromised account, or a malicious insider) can modify historical log entries, the entire audit trail is questionable.

Database-level protections:

-- Revoke UPDATE and DELETE permissions on the audit table
-- for the application database user
REVOKE UPDATE, DELETE ON webhook_audit_log FROM app_user;

-- Only the application can INSERT
GRANT INSERT ON webhook_audit_log TO app_user;
GRANT SELECT ON webhook_audit_log TO app_user;
GRANT USAGE, SELECT ON SEQUENCE webhook_audit_log_id_seq TO app_user;

Your application database user (app_user) can insert new records and read existing ones, but cannot modify or delete them. Only a database administrator with elevated privileges can alter the table, and that access should be tightly controlled and audited itself.

Application-level protections:

// The audit log module only exposes insert and read functions.
// There is no update or delete function.

const auditLog = {
  async insert(entry) {
    const redactedPayload = redactPayload(entry.payload);

    await db.query(
      'INSERT INTO webhook_audit_log ' +
      '(event_type, reference, amount_minor, currency, customer_email, ' +
      'customer_code, domain, raw_payload, signature_header, signature_valid, ' +
      'received_at, source_ip, processing_outcome, error_message, server_version) ' +
      'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)',
      [
        entry.eventType,
        entry.reference,
        entry.amount,
        entry.currency,
        entry.customerEmail,
        entry.customerCode,
        entry.domain,
        JSON.stringify(redactedPayload),
        entry.signatureHeader,
        entry.signatureValid,
        entry.receivedAt,
        entry.sourceIp,
        entry.processingOutcome,
        entry.errorMessage,
        process.env.APP_VERSION || 'unknown',
      ]
    );
  },

  async findByReference(reference) {
    const result = await db.query(
      'SELECT * FROM webhook_audit_log WHERE reference = $1 ORDER BY received_at',
      [reference]
    );
    return result.rows;
  },

  async findByDateRange(from, to) {
    const result = await db.query(
      'SELECT * FROM webhook_audit_log WHERE received_at BETWEEN $1 AND $2 ORDER BY received_at',
      [from, to]
    );
    return result.rows;
  },

  // No update() or delete() method exists
};

By not providing update or delete functions in your application code, you make it harder to accidentally or intentionally modify audit records. An engineer would need direct database access to alter them, and that access should require approval and leave its own audit trail.

Retention Policies by Jurisdiction

How long you keep audit logs depends on where your business is registered and which regulations apply to you. Here are guidelines for common African markets:

Jurisdiction Minimum Retention Notes
Nigeria (CBN) 5 years CBN guidelines for financial records. Longer for AML/CTF records.
Kenya (CBK) 7 years Proceeds of Crime and Anti-Money Laundering Act requires 7 years for transaction records.
South Africa (SARB) 5 years Financial Intelligence Centre Act. Tax records may require longer.
Ghana (BoG) 5 years Bank of Ghana payment system regulations.
PCI DSS 1 year Minimum for audit trail availability. 3 months immediately accessible.

When in doubt, retain for 7 years. Storage is cheap. Regulatory fines are not.

Implement a tiered retention strategy:

-- Records less than 3 months: keep in the main table (fast access)
-- Records 3 months to 2 years: keep in the main table (queryable)
-- Records 2 to 7 years: archive to a separate table or cold storage

-- Archive old records quarterly
INSERT INTO webhook_audit_log_archive
SELECT * FROM webhook_audit_log
WHERE received_at < NOW() - INTERVAL '2 years';

DELETE FROM webhook_audit_log
WHERE received_at < NOW() - INTERVAL '2 years'
AND id IN (SELECT id FROM webhook_audit_log_archive);

The archive table has the same schema as the main table. It can be stored on cheaper storage (a different tablespace, a different database, or exported to S3/GCS as compressed JSON files). The important thing is that the data is retrievable when an auditor asks for it, even if retrieval takes minutes instead of milliseconds.

Queries an Auditor Will Ask

When an auditor or regulator asks for information, you need to produce it quickly and completely. Here are the common queries:

"Show me all transactions for customer X between date A and date B."

SELECT event_type, reference, amount_minor, currency,
       received_at, processing_outcome
FROM webhook_audit_log
WHERE customer_email = 'customer@example.com'
AND received_at BETWEEN '2026-01-01' AND '2026-06-30'
ORDER BY received_at;

"Show me the complete event history for transaction reference Y."

SELECT event_type, received_at, processing_outcome,
       signature_valid, error_message, server_version
FROM webhook_audit_log
WHERE reference = 'order-ref-12345'
ORDER BY received_at;

"How many failed webhook events did you have in Q2 2026?"

SELECT
  event_type,
  processing_outcome,
  COUNT(*) as event_count
FROM webhook_audit_log
WHERE received_at BETWEEN '2026-04-01' AND '2026-06-30'
AND processing_outcome = 'failed'
GROUP BY event_type, processing_outcome
ORDER BY event_count DESC;

"Were there any invalid signature events (potential forgery attempts)?"

SELECT received_at, source_ip, event_type,
       raw_payload->>'event' as claimed_event
FROM webhook_audit_log
WHERE signature_valid = false
AND received_at > NOW() - INTERVAL '90 days'
ORDER BY received_at DESC;

"Show me all disputed transactions and how they were handled."

SELECT reference, event_type, received_at, processing_outcome
FROM webhook_audit_log
WHERE event_type IN ('dispute.create', 'dispute.remind')
AND received_at > NOW() - INTERVAL '1 year'
ORDER BY reference, received_at;

The ability to answer these queries quickly, accurately, and completely is the difference between passing and failing an audit. Build the indexes shown in the table definition above, and test these queries regularly to make sure they return results in reasonable time even as your data grows.

The Complete Logging Integration

Here is how the audit logging integrates with your webhook handler:

app.post(
  '/webhooks/paystack',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const receivedAt = new Date().toISOString();
    const rawBody = req.body;
    const signatureHeader = req.headers['x-paystack-signature'] || '';
    const sourceIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

    // Verify signature
    const signatureValid = verifySignature(rawBody, signatureHeader);
    const event = JSON.parse(rawBody.toString());

    // Log the event regardless of signature validity
    const logEntry = {
      eventType: event.event || 'unknown',
      reference: (event.data && event.data.reference) || null,
      amount: (event.data && event.data.amount) || null,
      currency: (event.data && event.data.currency) || null,
      customerEmail: (event.data && event.data.customer && event.data.customer.email) || null,
      customerCode: (event.data && event.data.customer && event.data.customer.customer_code) || null,
      domain: (event.data && event.data.domain) || null,
      payload: event,
      signatureHeader: signatureHeader,
      signatureValid: signatureValid,
      receivedAt: receivedAt,
      sourceIp: sourceIp,
      processingOutcome: 'received',
      errorMessage: null,
    };

    if (!signatureValid) {
      logEntry.processingOutcome = 'signature_failed';
      await auditLog.insert(logEntry);
      return res.status(400).send('Invalid signature');
    }

    // Return 200 immediately
    res.status(200).send('OK');

    // Process and update the audit log
    try {
      const result = await processEvent(event);
      logEntry.processingOutcome = result.skipped ? 'duplicate_skipped' : 'processed';
    } catch (err) {
      logEntry.processingOutcome = 'failed';
      logEntry.errorMessage = err.message;
    }

    await auditLog.insert(logEntry);
  }
);

Every path through the handler produces an audit log entry: valid signature and processed, valid signature but duplicate, valid signature but processing failed, and invalid signature. The audit trail is complete.

For monitoring these logs in real time, see monitoring and alerting on webhook failures. For handling permanently failed events, see building a dead letter queue.

Key Takeaways

  • Store every webhook event you receive, including events you do not process (unknown types, duplicates). The audit trail must be complete.
  • Redact sensitive fields before logging: card PANs, CVVs, and full account numbers. Paystack masks most of these, but verify your logs contain only masked values.
  • Make audit log entries immutable. Use an append-only table or write-once storage. Never UPDATE or DELETE audit records.
  • Set a retention policy based on your jurisdiction. PCI DSS requires 1 year minimum. Many African regulators require 5 to 7 years for financial records.
  • Include enough context in each log entry to reconstruct the full event timeline for any transaction: timestamps, event type, reference, processing outcome.
  • Separate audit logs from operational logs. Operational logs can be rotated and deleted. Audit logs must survive for years.

Frequently Asked Questions

What data should I redact from Paystack webhook logs?
Redact full card numbers (PANs), CVVs, authorization codes, and full bank account numbers. Paystack masks most of these before sending, but add your own redaction as a safety net. Keep first six and last four digits of cards (already provided by Paystack) for identification purposes.
How long should I keep Paystack webhook audit logs?
It depends on your jurisdiction. PCI DSS requires a minimum of 1 year. Nigerian CBN guidelines suggest 5 years for financial records. Kenyan law requires 7 years for transaction records under anti-money laundering regulations. When in doubt, retain for 7 years.
Should I log webhook events that fail signature verification?
Yes. Failed signature events might indicate a configuration problem (wrong secret key) or an attack attempt (someone sending forged webhooks). Logging them with the source IP address creates a record for security investigation.
How do I make webhook audit logs immutable?
Revoke UPDATE and DELETE permissions on the audit table for your application database user. Only grant INSERT and SELECT. In your application code, expose only insert and read functions with no update or delete capability. This ensures records cannot be altered after creation.
Can I store Paystack webhook logs in a file instead of a database?
For audit purposes, a database is better because it is queryable and you can produce specific records on demand. If you must use files, use append-only log files, include structured JSON per line, store them on write-once or versioned storage (S3 with object lock), and build tooling to search them efficiently.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon