Filter concepts by levelShowing all levels.

Django · Payment and Webhook Workflows

Payment and Webhook Workflows

The browser is a hint; the provider is the evidence

  • Never trust the success redirectit proves the user reached a URL — replayable, bookmarkable, forgeable
  • request.body FIRSTparsing changes the bytes the signature covers, and reading the stream makes body raise
  • Check the timestamp, not just the signaturea signature alone stays valid forever — captured events replay
  • At-least-once and unordereddedupe on (provider, event_id); rank states and never move backwards
  • Reconciliation is the correct pathwebhooks are only the fast one — a stuck payment looks exactly like a working one

1.The payment state model, and who is allowed to say "paid"

The section states its rule first: **never assume a client-side success callback is the authoritative payment source.** The browser being redirected to `/success` proves the user reached a URL, nothing more — that request can be replayed, bookmarked, or typed. Only the provider, speaking server-to-server, can confirm money moved. So the payment gets its own model with its own state machine, separate from the order, and it changes state on provider events rather than on anything a browser says.

Key features

  • A browser redirect proves the user reached a URL — it can be replayed, bookmarked or typed
  • Only a server-to-server signal (webhook, or a status fetch you initiate) is evidence
  • Model intent, attempts and outcome separately: one payment, many attempts
  • Store the provider reference on the row — it is the join key for webhooks, refunds and disputes
  • `DecimalField` with an explicit currency, never `FloatField` — 0.10 has no exact binary form
  • The success page reads state and may show "confirming"; it must not grant anything

What each signal actually proves

What each signal actually proves
SignalComes viaProves
redirect to `/success`the user's browserthe user reached a URL — nothing about money
a JS callback in the pagethe user's browserthe same; both are forgeable
a provider webhookprovider → your serverthe event, **once the signature is verified**
a status fetch you initiateyour server → providerthe strongest: you chose when to ask
a settlement reportprovider, dailythe reconciliation ground truth

The payment machine, and who may cause each move

The payment machine, and who may cause each move
FromToCaused by
`created``processing`your server, on submitting the attempt
`processing``succeeded`**the provider only**
`processing``failed`the provider (decline, expiry, timeout)
`processing``requires_action`the provider (3-D Secure and similar)
`succeeded``refunded` / `partially_refunded`your server initiates; the provider confirms
`succeeded``disputed`the provider, possibly months later
`failed`— terminala retry is a **new** payment, not a re-entry
The payment machine — and which transitions only the provider may cause
you submitthe attemptprovider:authentication neededcustomercompletes itverifiedprovider eventdeclined, expired,timed outyou initiate,provider confirmschargeback — long afterthe order shipped

created (intent — your system)

start

processing (attempt submitted)

requires_action (3-D Secure)

succeeded (provider only)

failed — terminal (a retry is a NEW payment)

end

refunded

end

disputed (weeks or months later)

end

  • created (intent — your system) (start)
    • → processing (attempt submitted) when you submit the attempt
  • processing (attempt submitted)
    • → requires_action (3-D Secure) when provider: authentication needed
    • → succeeded (provider only) when verified provider event
    • → failed — terminal (a retry is a NEW payment) when declined, expired, timed out
  • requires_action (3-D Secure)
    • → processing (attempt submitted) when customer completes it
  • succeeded (provider only)
    • → refunded when you initiate, provider confirms
    • → disputed (weeks or months later) when chargeback — long after the order shipped
  • failed — terminal (a retry is a NEW payment) (end)
  • refunded (end)
  • disputed (weeks or months later) (end)

Command

python
amount = models.DecimalField(max_digits=12, decimal_places=2)   # never FloatField

A browser redirect proves the user reached a URL; only a server-to-server provider signal proves money moved — so the success page reads state and may honestly say "confirming", while the webhook grants. Model intent, attempts and outcome separately: one payment can have several attempts, and overwriting the reference destroys the key their webhooks arrive on. Money is `Decimal` with an explicit currency, never a float. And encode the trust rule as code — a `source` argument on the transition, so a path holding a browser callback cannot reach `succeeded` at all.

2.Verifying a webhook, and why the raw body matters

A webhook endpoint is a public URL that moves money, so anyone can post to it and the first thing it must do is prove the request came from the provider. Verification is a signature over the **exact bytes** that were sent, which is why the raw body matters: parse the JSON first and re-serialise it, and the bytes change — a reordered key or different spacing — so a valid signature fails. Read `request.body` before anything else touches the request stream, compare with `hmac.compare_digest`, and only then parse.

Key features

  • `request.body` is "the raw HTTP request body as a bytestring" — verify against it
  • Reading the stream first makes `body` raise `RawPostDataException` — read `body` first, always
  • `json.loads` then `json.dumps` changes the bytes, so a re-serialised payload never verifies
  • Use `hmac.compare_digest`, not `==` — string comparison leaks timing information
  • Check the timestamp too: a signature alone is replayable forever
  • `csrf_exempt` is required and is safe *only* because the signature proves origin

Three checks, three attacks

Three checks, three attacks
CheckBlocksIf you skip it
signature over the raw bodya forged eventanyone who knows the URL can mark payments succeeded
same signaturea tampered amounta captured event can be edited before forwarding
timestamp within tolerancea replaylast month's valid event still verifies today
`compare_digest`a timing attackresponse time leaks how many bytes matched
(after all three) the event ida duplicatecovered by the next concept

What destroys the bytes the signature covers

What destroys the bytes the signature covers
Doing thisEffect on verification
`request.body`correct — the transmitted bytes
`json.loads(...)` then `json.dumps(...)`key order and spacing change; fails
`request.POST`form parsing; also consumes the stream
a DRF serializer before verifyingparsed and re-rendered; fails
`request.read()` then `request.body``RawPostDataException`
a proxy that re-encodes bodiesfails — and is very hard to diagnose
The order of operations in a webhook view — and what each step refuses

1 · Take the raw bytes, first

Before parsing, before any decorator touches the stream. Reading the stream first makes `body` raise `RawPostDataException`.

2 · Check freshness

A signature is valid forever. Without a timestamp window, a captured "payment succeeded" replays next month and verifies perfectly.

3 · Verify in constant time

`==` returns as soon as two bytes differ, so timing reveals how many leading bytes matched. `compare_digest` does not.

4 · Only now, parse

The bytes have served their purpose. Everything after this point is operating on data whose origin is proven.

5 · Acknowledge fast

Return 2xx quickly and do the work in a task. A slow endpoint makes the provider time out and retry, multiplying the load you were already struggling with.

  1. 1 · Take the raw bytes, first — Before parsing, before any decorator touches the stream. Reading the stream first makes `body` raise `RawPostDataException`.
  2. 2 · Check freshness — A signature is valid forever. Without a timestamp window, a captured "payment succeeded" replays next month and verifies perfectly.
  3. 3 · Verify in constant time — `==` returns as soon as two bytes differ, so timing reveals how many leading bytes matched. `compare_digest` does not.
  4. 4 · Only now, parse — The bytes have served their purpose. Everything after this point is operating on data whose origin is proven.
  5. 5 · Acknowledge fast — Return 2xx quickly and do the work in a task. A slow endpoint makes the provider time out and retry, multiplying the load you were already struggling with.

Command

python
raw = request.body   # before json.loads, before request.POST, before anything

Read `request.body` first — before `json.loads`, before `request.POST`, before anything touches the stream, because parsing changes the bytes the signature covers and reading the stream makes `body` raise. Check the timestamp as well as the signature, or a captured event replays forever. Compare with `hmac.compare_digest`, never `==`. Keep a list of valid secrets so rotation needs no outage. `csrf_exempt` is required and is only safe because the signature is doing CSRF's job — an exempt endpoint with weak verification is the worst case here. Then store, acknowledge in milliseconds, and process in a worker.

3.Duplicates, out-of-order events, and where the transaction goes

Webhook delivery is **at-least-once and unordered**. The same event will arrive twice — after a timeout, after a retry, or because the provider simply re-sent it — and a later event can arrive before an earlier one, so `payment.succeeded` can land after `payment.refunded`. Neither is a bug to fix at the provider; both are conditions your handler has to be correct under. Deduplicate on the provider's event id with a unique constraint, and refuse to move a payment backwards.

Key features

  • Delivery is at-least-once and unordered — both are normal, not provider bugs
  • Deduplicate on `(provider, event_id)` with a `UniqueConstraint`, not an `.exists()` check
  • Django documents the race in `get_or_create` — the constraint is the real guarantee
  • Rank the states and apply monotonically: never move a payment backwards
  • Prefer a provider-supplied sequence or version number to your own ranking
  • One `atomic()` for the effects; `on_commit` for anything irreversible

Four ways the same event can misbehave, and the one defence for each

Four ways the same event can misbehave, and the one defence for each
SituationWhat arrivesDefence
provider retried after a timeoutthe same event id, twiceunique constraint on the event id
two deliveries land concurrentlythe same id, in parallelthe constraint again — `.exists()` races
a late `succeeded` after `refunded`an older event, laterstate ranking: refuse to go backwards
two events in one secondambiguous orderingthe provider's sequence number, if there is one
handler crashes mid-waynothing — until the retryone transaction, so it is all-or-nothing
Three deliveries of two events — one duplicate, one out of order
Provider
Webhook view
Worker
Database
  1. 1evt_A "succeeded"
  2. 2INSERT WebhookEvent(evt_A)unique on (provider, event_id)
  3. 3200 — in milliseconds
  4. 4apply: processing → succeeded
  5. 5evt_B "refunded"
  6. 6apply: succeeded → refunded
  7. 7evt_A again (their retry timed out)at-least-once: this is normal
  8. 8INSERT evt_A → IntegrityErrorcaught; already recorded
  9. 9200 — do not 500 on a duplicatea 500 here just causes more retries
  10. 10rank("succeeded") ≤ rank("refunded") → ignorethe refund survives the late duplicate
  1. Provider → Webhook view: evt_A "succeeded"
  2. Webhook view → Database: INSERT WebhookEvent(evt_A) (unique on (provider, event_id))
  3. Webhook view → Provider: 200 — in milliseconds
  4. Worker → Database: apply: processing → succeeded
  5. Provider → Webhook view: evt_B "refunded"
  6. Worker → Database: apply: succeeded → refunded
  7. Provider → Webhook view: evt_A again (their retry timed out) (at-least-once: this is normal)
  8. Webhook view → Database: INSERT evt_A → IntegrityError (caught; already recorded)
  9. Webhook view → Provider: 200 — do not 500 on a duplicate (a 500 here just causes more retries)
  10. Worker → Database: rank("succeeded") ≤ rank("refunded") → ignore (the refund survives the late duplicate)

Command

python
UniqueConstraint(fields=["provider", "event_id"], name="uniq_webhook_event")

Webhooks are at-least-once and unordered, and both are normal. Deduplicate on `(provider, event_id)` with a real `UniqueConstraint` — `.exists()` and even bare `get_or_create` race, and two concurrent deliveries do happen. Apply *monotonically*: rank the states and ignore anything that would move a payment backwards, which handles late arrivals and duplicates with one rule; mark those events processed rather than retrying them. Put the effects in one `atomic()` and the irreversible work in `on_commit`. And always answer 200 for a duplicate — a 500 asks for the retry you were trying to prevent.

4.Reconciliation, retries, and the audit trail money requires

Webhooks get lost. Your endpoint was down for ten minutes, a delivery exhausted its retries, a deploy dropped in-flight requests — and the provider eventually stops trying. **Reconciliation** is the scheduled job that closes that gap by comparing your records against the provider's and fixing the differences, and it is what makes the whole design safe rather than merely usual. **Retry handling** decides which failures are worth another attempt. **Audit logs** are not optional here, because money disputes are settled by records.

Key features

  • A payment stuck in `processing` is indistinguishable from one still processing — only the provider knows
  • Reconciliation applies its findings through the *same* transition path a webhook uses
  • A rising reconciliation count is a webhook-delivery alarm, not just a fix
  • A timeout is ambiguous, not failed — retry with the provider's idempotency key, never blindly
  • The daily settlement file catches what per-payment checks cannot, including payments you never recorded
  • Keep payment audit rows past the dispute window — a chargeback can arrive months later

What each safety net catches

What each safety net catches
MechanismCatchesMisses
webhook handlereverything deliveredanything not delivered
status reconciliation (hourly)payments stuck non-terminalpayments you have no row for
settlement file (daily)missing rows, amount mismatches, feesnothing — it is the ledger
a stuck-payment alerta degrading webhook endpointa slow, steady leak
audit rowsthe "what did you know?" questionnothing, if kept long enough

Retrying a payment request, by what the failure means

Retrying a payment request, by what the failure means
FailureRetry?How
connection refused, DNS failureyesnothing was sent — plain backoff
**timeout after sending**carefully**ambiguous** — same idempotency key, or query status first
provider 5xxyessame idempotency key
429 rate limitedyeshonour the retry-after
card declinednoterminal — a new attempt is a new decision by the customer
invalid request (4xx)noa bug; fix the call, do not retry it
The fast path, the safety net, and the ledger
selectedby agesame pathcount > 0daily truth

Attempt submitted

idempotency key stored on the attempt row

Webhook arrives

the fast path — seconds

Webhook never arrives

endpoint down, retries exhausted, deploy dropped it

Stuck in processing

indistinguishable from genuinely in progress

Reconciliation, hourly

ask the provider for the current status

Same transition path

never a second writer of payment state

Settlement file, daily

the ledger — catches payments you never recorded

Alert on a rising count

reconciliation finding more = delivery degrading

Append-only audit

kept past the dispute window

  • Attempt submitted — idempotency key stored on the attempt row
    • leads to Webhook arrives
    • on error, leads to Webhook never arrives
  • Webhook arrives — the fast path — seconds
    • leads to Same transition path
  • Webhook never arrives — endpoint down, retries exhausted, deploy dropped it
    • on error, leads to Stuck in processing
  • Stuck in processing — indistinguishable from genuinely in progress
    • leads to Reconciliation, hourly (selected by age)
  • Reconciliation, hourly — ask the provider for the current status
    • leads to Same transition path (same path)
    • on error, leads to Alert on a rising count (count > 0)
  • Same transition path — never a second writer of payment state
    • leads to Append-only audit
  • Settlement file, daily — the ledger — catches payments you never recorded
    • leads to Same transition path (daily truth)
  • Alert on a rising count — reconciliation finding more = delivery degrading
  • Append-only audit — kept past the dispute window

Command

python
provider.charge(amount, idempotency_key=str(attempt.uuid))   # same key on retry

Webhooks are the fast path; reconciliation is the correct one, and a system with only the first is silently wrong whenever delivery fails — a payment stuck in `processing` looks exactly like one still processing. Run a scheduled job that asks the provider about overdue payments, apply its findings through the *same* `transition_to` path, and alert on a rising count, because that is your webhook-delivery alarm. Compare the daily settlement file too: it catches payments you never recorded at all. Treat a timeout as ambiguous rather than failed — retry with the provider's idempotency key. And keep payment audit rows past the dispute window, because a chargeback arrives months later.

Remember

  • `json.dumps(json.loads(x))` is not `x` — a re-serialised payload can never verify.
  • `hmac.compare_digest`, not `==`. String comparison leaks how many leading bytes matched.
  • A duplicate webhook is a 200, not a 500 — a 500 asks the provider for another delivery.
  • A timeout is ambiguous, not failed. Retry with the same idempotency key or you may charge twice.

Pro tip

  • Keep a list of valid webhook secrets, not one. Rotation then becomes an ordinary deploy instead of a scheduled outage.
  • Give the reconciliation command its own exit code for "found drift". A rising count is a webhook-delivery alarm, not just a repair.

Next up

  • Event-Driven Architecture
  • Reliability Patterns
  • Linux for Django Developers

Sources

  • Request and response objects (body, RawPostDataException) — docs.djangoproject.com/en/5.2/ref/request-response/
  • QuerySet API (get_or_create and its race) — docs.djangoproject.com/en/5.2/ref/models/querysets/
  • Database transactions — docs.djangoproject.com/en/5.2/topics/db/transactions/
  • hmac.compare_digest — docs.python.org/3/library/hmac.html
  • Every claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.