Part 2 of 7 · Chargeback responder series ~7 min read

How a dispute gets caught

Everything downstream depends on two things landing correctly the instant a dispute opens: that it really came from your processor, and exactly when the bank’s evidence is due. This post is about the front door — a Stripe-style dispute webhook arriving on a Lambda Function URL, the signature check that stops a forged one, the de-duplication that survives the processor’s retries, and the deadline that gets written down and armed with a watch so nothing can quietly run out of time.

Key takeaways

  • The dispute webhook lands on a Lambda Function URL — no API Gateway, just a verified front door.
  • The processor’s signature is checked first; an unsigned or forged dispute never gets past the front door.
  • Every event is de-duplicated by its dispute id, so the processor’s retries can’t open the same case twice.
  • The bank’s evidence deadline is read straight off the dispute and written down before anything else happens.
  • A one-off deadline watch is armed the instant the dispute is caught — the case can never silently run out of time.

The front door: a Function URL, not an API Gateway

A dispute arrives as an HTTP POST from the processor — a JSON body describing the event, signed with a shared secret. There’s exactly one caller (your processor), low volume (a handful of disputes a day at most), and no need for the routing, throttling, or request-validation machinery an API Gateway brings. So the endpoint is a plain Lambda Function URL: a single HTTPS address that invokes one function directly. It costs nothing when idle and rounds to nothing under load at this scale. The Function URL is set to AuthType: NONE — the request is authenticated by the processor’s signature, checked inside the function, not by IAM.

Verify the signature before trusting a single field

The very first thing the function does — before it reads the amount, the reason code, or anything else — is verify the request really came from your processor. The processor signs each webhook with a secret you both hold; it sends the signature in a header (Stripe puts it in Stripe-Signature with a timestamp and an HMAC of the raw body). The function recomputes the HMAC over the raw body using the signing secret from Secrets Manager, compares it in constant time, and checks the timestamp is recent so an old captured request can’t be replayed. If the signature doesn’t match, the function returns 400 and stops. Nothing else in the system ever sees an unverified dispute.

De-duplicate, then write the deadline down

Processors retry webhooks — if your endpoint is slow or returns an error, the same dispute event may arrive several times, and the created and an early updated can look almost identical. So the function de-duplicates on the dispute id with a conditional write: it tries to create the row in the disputes table only if one doesn’t already exist. A duplicate loses the race, the function returns 200 (so the processor stops retrying), and no second case is opened. On the winning write it reads the two fields that matter most — the reason code (which decides what evidence the packet will need) and evidence_details.due_by (the bank’s hard deadline) — and records the deadline in its own table before doing anything slower.

The path of a dispute webhook from the processor to an armed deadline watch A top-to-bottom flow. At the top, the payment processor posts a signed dispute webhook to a Lambda Function URL labelled cbr-webhook. Inside the AWS account boundary, the request passes through four sequential gates drawn as boxes connected by downward arrows. First gate: verify signature — recompute the HMAC over the raw body with the secret from Secrets Manager and check the timestamp; on failure it returns 400 and stops, shown as a branch arrow to a reject box. Second gate: de-duplicate — a conditional write on the dispute id into the disputes table; a duplicate returns 200 and stops, shown as a branch to a stop box. Third gate: record the deadline — read the reason code and the evidence due-by timestamp and write a row to the deadlines table. Fourth gate: arm the watch — create a one-off EventBridge Scheduler job set to fire a safe margin before the due-by. After the four gates, a final arrow emits a dispute-received event onto the EventBridge bus and enqueues the gather job on SQS, which the next post picks up. A note at the bottom reads: nothing slower than a database write happens until the deadline is safely recorded and watched. Payment processor signed dispute webhook AWS account · cbr-webhook (Function URL) 1 · Verify signature HMAC over raw body, recent timestamp bad → 400 2 · De-duplicate conditional write on dispute id dup → 200 3 · Record the deadline reason code + due_by → deadlines table 4 · Arm the deadline watch one-off Scheduler job, safe margin before due emit dispute-received → SQS gather Nothing slower than a database write happens until the deadline is recorded and watched.
Fig 2. The front door, gate by gate. Verify the signature, de-duplicate on the dispute id, record the deadline, arm the one-off watch — then, and only then, hand the case to the evidence engine.

Arm the watch before anything slow happens

Here’s the ordering that matters: the function records the deadline and arms its watch before it kicks off the slow work of gathering evidence. The moment the deadline is in the table, the function creates a one-off EventBridge Scheduler job — an at(...) rule timed for a safe margin before due_by (a day, by default) — targeting the deadline-guard Lambda. If everything downstream went perfectly, that job will find the case already filed and do nothing. But if gathering stalls, or the owner never taps a button, the watch is the backstop that still files before the bank’s cutoff. Arming it first means the safety net exists even if the next step crashes.

Only after the deadline is recorded and watched does the function emit a dispute.received event onto the EventBridge bus and drop a job on the gather queue. It returns 200 to the processor in well under a second — the heavy lifting happens asynchronously, off the webhook’s critical path, which is exactly why a slow carrier API can never cause the processor to think the webhook failed and retry.

Why this order, every time

The sequence — verify, de-duplicate, record the deadline, arm the watch, then hand off — is deliberate. Verifying first means no forged or replayed dispute ever touches your data. De-duplicating second means the processor’s retries are free; the endpoint is idempotent by construction. Recording and watching the deadline third and fourth means the one thing you can never recover from — a missed filing window — is locked in before any fragile, slow, or failable work begins. Everything after the front door can be retried; the deadline cannot be un-missed. So the front door’s whole job is to make the deadline safe, fast, and exactly once.

Design rules for the front door

  • One Function URL, signature-checked. No API Gateway, and no field is trusted before the HMAC verifies.
  • Idempotent by dispute id. The processor can retry the same event forever and only one case is ever opened.
  • Deadline first, evidence second. The bank’s due-by is written and watched before any slow work runs.
  • Arm the watch eagerly. The safety net is created up front, so a crash downstream still can’t cause a late filing.
  • Answer the processor fast. Acknowledge in under a second and do the real work asynchronously off the webhook path.
All posts