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.
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.