How a failed charge gets caught
Everything downstream depends on hearing about the failed charge promptly and exactly once. This post is about the front door: a Stripe-style webhook lands on a Lambda Function URL, the system verifies it really came from the processor, throws away duplicates and replays, and records that this subscription has entered dunning — without trusting anything the request claims about money.
Key takeaways
- The processor fires a signed webhook the instant a renewal charge fails; a Lambda Function URL catches it.
- The signature is verified against the webhook secret before anything is trusted — an unsigned request does nothing.
- Events are de-duplicated by id, because processors retry their own webhooks and replays are normal.
- The handler trusts no amount in the request; it records ids and lets the retrier read real figures back from the processor.
- It returns
200in milliseconds and hands the work to SQS, so the processor never sees a slow endpoint.
The front door is a webhook, not a poll
The system finds out about a failed charge the same way everything else in a Stripe-style processor finds out: a webhook. When a subscription’s renewal charge is declined, the processor immediately fires an event — invoice.payment_failed, or your processor’s equivalent — to an HTTPS endpoint you registered. The alternative, polling the processor every few minutes asking “anything failed yet?”, would be slower, cost more, and miss the exact moment. So the front door is a single endpoint that listens.
That endpoint is a Lambda Function URL — a plain HTTPS address that invokes dunr-webhook directly. No API Gateway sits in front of it; for one webhook receiver, the Function URL does the whole job and costs nothing per request. The processor is configured to send the four events that matter: a charge failed, a charge succeeded, a payment method was attached or updated, and a subscription was cancelled in the billing tool. Everything the system does begins here.
Three checks before anything is trusted
A public URL that puts customers into a payment-recovery flow is exactly the kind of thing you don’t want fired by a random request. So dunr-webhook does three things, in order, before it believes a word of the payload.
- Verify the signature. The processor signs every webhook with a shared secret and puts the signature in a header. The handler reads the webhook signing secret from Secrets Manager (
dunr/stripe/webhook-secret), recomputes the HMAC over the raw request body and the timestamp, and compares. If it doesn’t match — or the timestamp is older than five minutes, which blocks replay of a captured request — the handler returns400and stops. An attacker who doesn’t have the secret cannot make this system do anything. - De-duplicate by event id. Processors guarantee at-least-once webhook delivery, which means they will happily send the same event twice if they don’t get a fast
200. Every event carries a unique id. The handler does a conditional write of that id to a smalldunr-dedupetable (with a 30-day TTL); if the id is already there, this is a duplicate and the handler returns200immediately without doing anything again. This is the first line of defence against acting on the same failure twice. - Trust ids, not amounts. The handler reads only the identifiers from the payload — the subscription id, the invoice id, the customer id, and the failure reason code. It deliberately does not trust any amount or currency in the request body as gospel; when the retrier later attempts the charge, it reads the live invoice from the processor using its API key, so the figure charged is always the processor’s real number, not whatever arrived in a webhook.
Entering dunning, then handing off
Once an event is verified, fresh, and parsed, the handler does the smallest possible amount of work and gets out of the way.
dunr-webhook verifies the signature, drops duplicates by id, parses only the identifiers, marks the subscription retrying, queues the work, and returns 200 in milliseconds.It writes one row to the dunr-dunning table — keyed on the subscription id, marked retrying, recording the invoice id, the customer id, the failure code, and a timestamp — then drops a single message on the dunr-events SQS queue and returns 200. The decision-making (how to retry, when, what to say) all happens downstream, off the back of the queue. The handler itself is deliberately dumb: catch, verify, record, hand off.
Returning 200 fast matters more than it looks. If the endpoint is slow, the processor treats the webhook as failed and retries it — which is why de-duplication exists — and a backlog of slow responses can get your endpoint disabled by the processor. By doing nothing heavy in the handler and pushing real work onto SQS, the front door responds in milliseconds however busy the retry engine is. The SQS queue also has a dead-letter queue: if a message fails to process twice, it lands there with an alarm, so a single malformed event never silently blocks the pipeline or gets lost.
The other three events
The same front door handles the good news too. A charge.succeeded for a subscription that’s in dunning means a retry (or a fresh attempt after a card update) worked — the handler routes it to the recovery path that cancels remaining retries and restores access. A payment_method.updated means the customer fixed their card; the handler nudges the retrier to try again now rather than waiting for the next scheduled slot. And a subscription.cancelled — which only ever comes from a human acting in your billing tool — tells the system to stand down: cancel the schedule, stop the emails, and mark the row closed. The system never originates a cancellation; it only reacts to one.
Design rules that shaped the front door
- Verify before you trust. No signature, or a stale timestamp, means a
400and nothing happens. - Assume duplicates. At-least-once delivery is normal; de-duplicate by event id on a conditional write.
- Trust ids, not amounts. The real figure is read live from the processor at retry time, never from the webhook body.
- Answer fast, work later. Return
200in milliseconds; push the real work onto SQS with a dead-letter queue. - One door, four events. Failed, succeeded, card updated, cancelled — all arrive here and route from one place.
Next: how the retry engine takes that queued event and lays out a humane backoff schedule — the next business day, then every few business days, skipping weekends — and guarantees a charge is never attempted twice.
All posts