Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 073 / 100

Payment audit trails: a practical Payments & Stripe guide

Answer who changed what and when.

Expona Admin10 min read

Introduction

When a customer disputes a refund, your finance team asks who processed it and when. When an engineer accidentally captures a payment twice, you need to trace exactly what happened. Payment audit trails: a practical Payments & Stripe guide shows you how to build the logging infrastructure that answers those critical questions—who changed what and when—across every transaction lifecycle event in your payment system.

Audit trails are not optional compliance theater. They're operational insurance. They let you investigate fraud, support customer disputes, prove regulatory compliance, and debug payment logic errors under pressure. Building them well means capturing the right events, linking them to actors, storing them immutably, and keeping them long enough to matter.

Why audit trails matter in payment systems

Payment operations are high-velocity, high-stakes work. A single mistake—a refund processed twice, a manual charge adjustment made in the wrong currency, a capture authorized by someone without the right permissions—can cost thousands and damage customer trust. Without audit trails, you're left guessing. With them, you can reconstruct the exact sequence of decisions and actions that led to any outcome.

From a regulatory standpoint, many frameworks require you to demonstrate that payment changes are traceable to individual actors and timestamped. PCI DSS, SOX, and industry-specific rules (finance, healthcare, gambling) all expect audit logs. From a practical standpoint, your support team needs to explain to customers why a refund took three days, and your finance team needs to reconcile discrepancies between your system and Stripe's records.

Log every action: refunds, captures, and manual adjustments

Start by identifying which payment events matter enough to log. The obvious ones are high-risk: refund requests, voids, captures outside normal authorization flows, and manual transaction adjustments. But don't stop there. Log authorization failures, disputes, chargeback claims, Stripe webhook events, and internal staff actions like payment review or fraud flag changes.

Here's a practical example. Imagine a customer contacts support because they were refunded $50 but charged $50 again. You need to log:

  • The original authorization (amount, timestamp, user ID who triggered it)
  • The refund request (who initiated it, why, when, refund amount)
  • The refund execution (was it full or partial, Stripe refund ID)
  • Any subsequent charge (if one exists, or clarify that it was a decline)

Structure these logs as events. Each event is a fact about a single action. Here's a TypeScript schema that works in practice:

typescript
interface PaymentAuditEvent {
  id: string; // UUID
  eventType: 'authorization' | 'capture' | 'refund' | 'void' | 'dispute' | 'manual_adjustment' | 'webhook_received';
  paymentId: string; // Your internal payment ID
  stripeChargeId?: string; // If applicable
  actor: {
    userId: string; // Internal staff or system user ID
    email: string;
    role: 'admin' | 'support' | 'system' | 'api_key';
  };
  action: {
    type: string;
    amount?: number;
    currency?: string;
    reason?: string; // e.g., 'customer_request', 'duplicate_charge', 'fraud_decline'
    status: 'initiated' | 'succeeded' | 'failed';
    statusCode?: string;
  };
  stripeResponse?: Record; // Raw Stripe API response
  ipAddress?: string;
  userAgent?: string;
  createdAt: Date;
  metadata?: Record;
}

When you refund a charge through Stripe, log the refund event with the actor (which staff member, or "system" if automatic), the refund amount, the reason, and the Stripe refund ID. When a manual payment adjustment happens—say, a CSR marks a transaction as non-fraudulent after a review—log it. The key is completeness: every mutation to payment state should have a corresponding audit entry.

In practice, log events at the moment you make the change, not after it completes. If a Stripe API call fails, you still want to know someone tried. A failed refund attempt is as important as a successful one, because it tells you why a customer might still be waiting.

Immutable records: append-only event tables

Never update or delete audit events. Build your audit table as append-only. This is non-negotiable. If you allow updates or deletes, you lose the chain of evidence and invite tampering concerns—intentional or accidental.

Here's a PostgreSQL schema that enforces this:

typescript

CREATE TABLE payment_audit_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type VARCHAR NOT NULL,
  payment_id UUID NOT NULL,
  stripe_charge_id VARCHAR,
  actor_user_id UUID NOT NULL,
  actor_email VARCHAR NOT NULL,
  actor_role VARCHAR NOT NULL,
  action_type VARCHAR NOT NULL,
  action_amount DECIMAL,
  action_currency CHAR(3),
  action_reason VARCHAR,
  action_status VARCHAR NOT NULL,
  stripe_response JSONB,
  ip_address INET,
  user_agent TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  metadata JSONB,
  
  CONSTRAINT immutable_log CHECK (created_at IS NOT NULL)
);

CREATE INDEX idx_payment_id ON payment_audit_log(payment_id);
CREATE INDEX idx_stripe_charge_id ON payment_audit_log(stripe_charge_id);
CREATE INDEX idx_actor_user_id ON payment_audit_log(actor_user_id);
CREATE INDEX idx_created_at ON payment_audit_log(created_at);

-- Optional: prevent accidental deletes with a security definer trigger
CREATE OR REPLACE FUNCTION prevent_audit_delete()
RETURNS TRIGGER AS $$
BEGIN
  RAISE EXCEPTION 'Audit logs cannot be deleted';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER audit_delete_protection
  BEFORE DELETE ON payment_audit_log
  FOR EACH ROW EXECUTE FUNCTION prevent_audit_delete();

Never allow UPDATE on the log table. The only operation is INSERT. If you need to correct something, insert a new entry that explains the correction. This creates a full history of changes, not a hidden revision.

For high-compliance environments (finance, healthcare), consider writing audit logs to a separate, read-only database or an immutable log store (like an object store with versioning disabled). Some teams write to both a transactional database and a cloud blob store (AWS S3 with object lock, for example) for belt-and-suspenders protection.

Every audit event must include the actor. If a customer initiates a refund through your app, log "system" or "customer_api". If a support agent processes it manually, log their user ID, email, and role. If an automated fraud check triggers a decline, log "fraud_system" or similar.

Here's where it gets tricky: your internal staff might use multiple systems. A CSR might process a refund through your dashboard, through a Stripe API call, or through a batch job. You need a consistent way to attribute actions.

Best practice: use a request context or middleware to capture the authenticated user on every operation. In a NestJS or Express app:

typescript
// Middleware to capture actor context
export const captureActorMiddleware = (req: Request, res: Response, next: NextFunction) => {
  req.actor = {
    userId: req.user?.id || 'system',
    email: req.user?.email || 'system@internal',
    role: req.user?.role || 'system',
  };
  next();
};

// Later, in your refund service
async refundCharge(chargeId: string, amount: number, actor: Actor) {
  const refundResult = await stripe.refunds.create({
    charge: chargeId,
    amount,
  });

  await this.auditLog.insert({
    eventType: 'refund',
    paymentId: charge.paymentId,
    stripeChargeId: chargeId,
    actor,
    action: {
      type: 'refund',
      amount,
      status: 'succeeded',
    },
    stripeResponse: refundResult,
    createdAt: new Date(),
  });

  return refundResult;
}

When you make API calls to Stripe, also capture the IP address and user agent from the client (for browser-based actions). This helps detect unauthorized access. If a refund is always initiated from the office in New York, but suddenly one appears from Moscow, you have a signal.

Retention: keep records as long as finance requires

How long should you keep audit logs? This depends on your jurisdiction and business. US tax law generally requires financial records for 7 years. PCI DSS requires 1 year of logs. Some industries (gambling, finance) require longer. Check with your legal and finance teams, then add a buffer.

In practice, keep hot data (last 2–3 years) in your operational database. Archive older logs to cold storage (S3 Glacier, for example). This keeps queries fast and costs low.

Here's a retention strategy:

  • Active (0–1 year): PostgreSQL, indexed, fast queries. Used by support and finance teams daily.
  • Archive (1–7 years): Compressed JSON lines or Parquet in S3. Queryable via Athena or Redshift Spectrum, but slower.
  • Compliance (7+ years): Write-once, read-rarely. S3 Object Lock, immutable. For regulators or litigation only.

Implement a scheduled job that exports and archives logs older than your retention threshold:

typescript
// Daily archive job
async archiveAuditLogs() {
  const cutoffDate = new Date();
  cutoffDate.setFullYear(cutoffDate.getFullYear() - 1);

  const logsToArchive = await this.db.query(
    'SELECT * FROM payment_audit_log WHERE created_at < $1',
    [cutoffDate]
  );

  if (logsToArchive.length === 0) return;

  // Write to S3 as gzip-compressed JSONL
  const s3Key = `audit-archive/${cutoffDate.toISOString().split('T')[0]}.jsonl.gz`;
  const gzipStream = zlib.createGzip();
  const s3Stream = this.s3.upload({
    Bucket: 'audit-logs-archive',
    Key: s3Key,
    Body: gzipStream,
  }).promise();

  logsToArchive.forEach(log => {
    gzipStream.write(JSON.stringify(log) + '\n');
  });

  gzipStream.end();
  await s3Stream;

  // Delete from hot storage after successful archive
  await this.db.query(
    'DELETE FROM payment_audit_log WHERE created_at < $1',
    [cutoffDate]
  );
}

Common mistakes

Logging too late. If you log after the transaction completes, you miss failures. Log before, during, and after the action. Or better: log the attempt, then update a status field if you're tracking async operations.

Logging without context. A log entry that says "refund created" is useless if you don't know why, who asked for it, or what went wrong. Always include the reason, actor, and failure details.

Storing raw Stripe responses without interpretation. Stripe returns nested, sometimes cryptic JSON. Log the key fields (refund ID, status, error code) separately so you can query them without parsing JSON. Store the full response as metadata for debugging.

Forgetting to log Stripe webhook events. Stripe is a source of truth. When a webhook arrives (refund.created, charge.captured, etc.), log it immediately. This creates a bridge between your internal actions and Stripe's state, making disputes easier to resolve.

Allowing query-based deletion. Someone will eventually write a query to "clean up old logs" and accidentally delete recent ones. Enforce immutability at the database level with triggers or policies. Make deletion hard.

Querying audit logs in practice

Once you have immutable logs, you need to query them. A common scenario: a customer says they were refunded but still see a charge. Query like this:

typescript
async getPaymentHistory(paymentId: string) {
  const events = await this.db.query(
    `SELECT 
      event_type, 
      actor_email, 
      action_type, 
      action_amount, 
      action_status, 
      created_at
    FROM payment_audit_log
    WHERE payment_id = $1
    ORDER BY created_at ASC`,
    [paymentId]
  );

  return events;
}

// Example output:
// [
//   { event: 'authorization', actor: 'customer_api', amount: 50, status: 'succeeded', at: '2024-01-10T10:00:00Z' },
//   { event: 'capture', actor: 'system', amount: 50, status: 'succeeded', at: '2024-01-10T10:05:00Z' },
//   { event: 'refund', actor: 'support@company.com', amount: 50, status: 'succeeded', at: '2024-01-11T09:00:00Z' },
// ]

With this timeline, you can tell the customer exactly what happened and when. If a second charge appears, you'll see it in the logs too.

Key takeaways

  • Log every payment mutation: authorizations, captures, refunds, disputes, manual adjustments, and Stripe webhooks. Include actor, action, timestamp, and outcome.
  • Build audit tables as append-only. Use database constraints or policies to prevent updates and deletes. Treat logs as facts, not mutable records.
  • Capture the actor (user ID, email, role) on every payment action. Include IP address and user agent for high-security scenarios. Link actions to real people or systems.
  • Retain hot logs (1–2 years) in your operational database. Archive older logs to cold storage. Follow regulatory requirements (often 7 years for finance). Make deletion difficult or impossible.
  • Query logs by payment ID, actor, or date range. Use them to investigate disputes, prove compliance, and debug payment logic.
  • Don't log after the operation completes. Log the attempt, the outcome, and any errors. Include full Stripe responses as context, but also extract key fields into queryable columns.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles