Filter concepts by levelShowing all levels.

Django · Section 88

Payment and Webhook Workflows

Level
advanced
Read
40 min
Concepts
4

The section leads with its rule: never assume a client-side success callback is the authoritative payment source. A redirect to `/success` travels through the customer's browser, so it proves only that someone reached a URL — it can be replayed, bookmarked or typed with a guessed order id — and it is also incomplete, because a customer who closes the tab after authorising never reaches it. Only a server-to-server signal is evidence. So the payment gets its own model and its own machine, separate from the order, with the states that only the provider may cause named as data and enforced by a `source` argument on the transition. Model intent, attempts and outcome apart: one payment can have several attempts, and overwriting the provider reference on a retry destroys the key that the declined attempt's webhooks will arrive on. Money is `Decimal` with an explicit currency, never a float. The inbound half turns on bytes. A signature covers exactly what was transmitted, so verification must run against `request.body` — parse first and the key order and spacing change, and a valid signature fails; read the stream first and Django raises `RawPostDataException`. Check the timestamp as well, because a signature alone is valid forever and a captured event replays indefinitely; compare with `hmac.compare_digest`, since `==` leaks how many bytes matched; keep a list of valid secrets so rotation needs no outage; and store the event, acknowledge in milliseconds, and process in a worker, because a slow handler causes the timeouts that produce a retry storm. Then accept what delivery actually is: at-least-once and unordered. The same event will arrive twice, and `succeeded` can land after `refunded`. Deduplicate on `(provider, event_id)` with a real `UniqueConstraint` — `.exists()` races, and Django documents the same race in `get_or_create` — and apply monotonically by ranking the states, so a late event is ignored rather than allowed to undo a refund. Keep the effects in one `atomic()` and the irreversible work in `on_commit`, and answer 200 to a duplicate rather than a 500 that asks for another delivery. Finally, webhooks get lost, and a payment stuck in `processing` is indistinguishable from one still processing. Reconciliation is the job that asks the provider about overdue payments and applies the answer through the same transition path — never a second writer of payment state — with a rising count serving as the alarm that delivery is degrading, and the daily settlement file catching what per-payment checks cannot.

What is true here

  1. Only a verified server-to-server signal may move a payment to succeeded.
  2. Verify the raw transmitted bytes, with a freshness window and a constant-time comparison.
  3. Delivery is at-least-once and unordered: dedupe by id, apply monotonically.
  4. Store and acknowledge fast; do the work in a worker.
  5. Reconciliation covers the events that never arrive, and doubles as a delivery alarm.

What you will be able to do

  • Build a checkout that cannot be marked paid from a browser
  • Write a webhook endpoint that rejects forgeries and replays
  • Stay correct when the same event arrives three times, out of order
  • Notice a broken webhook endpoint before your customers do
One payment, end to end — and the three signals, only two of which are evidence
Browser
Django
Provider
Worker
  1. 1. POST /checkout
  2. 2. Payment(status="created") + PaymentAttemptintent and attempt are separate rows
  3. 3. charge(amount, idempotency_key=attempt.uuid)the key makes a retry safe
  4. 4. accepted — reference pi_88x
  5. 5. redirect to /successA HINT. Proves nothing about money — read state, grant nothing
  6. 6. POST /webhooks (signed, timestamped)EVIDENCE — but only after verification
  7. 7. body → timestamp window → compare_digest → parseraw bytes first; any parse breaks the signature
  8. 8. 200, in millisecondsstored, not processed — slow handlers cause retry storms
  9. 9. process_webhook_event(row.id)
  10. 10. lock · rank check · transition_to(source="provider")a stale or duplicate event is marked handled, not retried
  11. 11. the same event again (their retry)at-least-once is normal
  12. 12. 200 — already recordeda 500 here would ask for yet another delivery
  13. 13. hourly: status of overdue payments?EVIDENCE — reconciliation covers the events that never arrived
  1. Browser → Django: POST /checkout
  2. Django → Django: Payment(status="created") + PaymentAttempt (intent and attempt are separate rows)
  3. Django → Provider: charge(amount, idempotency_key=attempt.uuid) (the key makes a retry safe)
  4. Provider → Django: accepted — reference pi_88x
  5. Provider → Browser: redirect to /success (A HINT. Proves nothing about money — read state, grant nothing)
  6. Provider → Django: POST /webhooks (signed, timestamped) (EVIDENCE — but only after verification)
  7. Django → Django: body → timestamp window → compare_digest → parse (raw bytes first; any parse breaks the signature)
  8. Django → Provider: 200, in milliseconds (stored, not processed — slow handlers cause retry storms)
  9. Django → Worker: process_webhook_event(row.id)
  10. Worker → Worker: lock · rank check · transition_to(source="provider") (a stale or duplicate event is marked handled, not retried)
  11. Provider → Django: the same event again (their retry) (at-least-once is normal)
  12. Django → Provider: 200 — already recorded (a 500 here would ask for yet another delivery)
  13. Worker → Provider: hourly: status of overdue payments? (EVIDENCE — reconciliation covers the events that never arrived)

The model, and who may say "paid"

Intent, attempts and outcome as separate things — and the rule the section opens with.

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

coreadvanced

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.

Think of it as

Separate the three things that a naive implementation collapses into one boolean. There is the *intent* — the customer wants to pay £49 for order 88 — which your system creates and owns. There is the *attempt* — a specific interaction with the provider, which may fail and be retried, so one intent can have several. And there is the *outcome*, which only the provider knows, arrives asynchronously, and is the sole thing entitled to move the payment to `succeeded`. Modelling those separately is what makes the awkward cases expressible: a customer whose card is declined twice and then succeeds has one payment and three attempts; a customer who closes the tab after authorising has an intent with an outcome you will learn about by webhook rather than by redirect. Once the split exists, the trust rule becomes obvious rather than a special precaution. The redirect to your success page is a *hint* — good for showing a spinner, useless as evidence, because it travels through the user's browser where anyone can send the same request. The webhook and a server-side status fetch are evidence, because they come from the provider over a channel the user cannot forge. The practical consequence is that the success page must not grant anything; it reads the payment's current state and, if the webhook has not arrived yet, says "confirming" and polls. That feels worse than instantly showing "paid" and is the whole point. Two details in the model itself repay attention. Store money as `Decimal` with an explicit currency, never a float — binary floating point cannot represent 0.10 exactly, and errors accumulate across totals in ways that surface as one-penny reconciliation failures nobody can explain. And store the provider's own identifier on the row, because it is the join key for every webhook, every refund, and every dispute; without it, matching a provider event back to your record becomes guesswork over amounts and timestamps.

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

What we're doing: A payment model that records the provider reference, keeps money exact, separates attempts from the payment, and refuses to reach `succeeded` without provider evidence.

payments/models.pypython
class Payment(models.Model):
    class Status(models.TextChoices):
        CREATED = "created", "Created"
        PROCESSING = "processing", "Processing"
        REQUIRES_ACTION = "requires_action", "Requires action"
        SUCCEEDED = "succeeded", "Succeeded"
        FAILED = "failed", "Failed"
        REFUNDED = "refunded", "Refunded"
        DISPUTED = "disputed", "Disputed"

    # Only the provider may cause these. The transition method enforces it.
    PROVIDER_ONLY = {Status.SUCCEEDED, Status.FAILED, Status.DISPUTED}

    order = models.ForeignKey("orders.Order", on_delete=models.PROTECT,
                              related_name="payments")
    status = models.CharField(max_length=16, choices=Status.choices,
                              default=Status.CREATED, db_index=True)

    # Decimal, not float: 0.10 has no exact binary representation, and the
    # error compounds across totals into reconciliation failures.
    amount = models.DecimalField(max_digits=12, decimal_places=2)
    currency = models.CharField(max_length=3)          # ISO 4217

    # The join key for every webhook, refund and dispute that follows.
    provider = models.CharField(max_length=32)
    provider_reference = models.CharField(max_length=128, blank=True, db_index=True)

    class Meta:
        constraints = [
            models.CheckConstraint(
                condition=models.Q(amount__gt=0), name="payment_amount_positive"
            ),
            # One provider reference maps to one payment, per provider.
            models.UniqueConstraint(
                fields=["provider", "provider_reference"],
                condition=~models.Q(provider_reference=""),
                name="uniq_provider_reference",
            ),
        ]

    def transition_to(self, target, *, source, event=None):
        """`source` is "provider" or "internal". The distinction is the
        section's core principle, expressed as code rather than as a
        convention somebody has to remember."""
        if target in self.PROVIDER_ONLY and source != "provider":
            raise PermissionDenied(
                f"{target} requires a verified provider event, not {source}"
            )
        ...                                            # then the usual checks


class PaymentAttempt(models.Model):
    """One payment, many attempts: a card declined twice then accepted is
    one intent with three attempts, not three payments."""
    payment = models.ForeignKey(Payment, on_delete=models.CASCADE,
                                related_name="attempts")
    provider_reference = models.CharField(max_length=128, db_index=True)
    outcome = models.CharField(max_length=32)
    failure_code = models.CharField(max_length=64, blank=True)
    occurred_at = models.DateTimeField(default=timezone.now)
11–12
Naming the provider-only states as data is what lets the rule be checked once, in `transition_to`, instead of being remembered at every call site.
20–22
`FloatField` for money is the classic defect: binary floating point cannot represent 0.10, so totals drift by fractions of a penny and only surface when reconciliation against the provider fails.
25–26
The provider reference is indexed because every inbound webhook looks the payment up by it. Without it, matching an event to a payment means guessing from amount and timestamp.
33–37
A unique constraint conditioned on the reference being non-empty — payments start with no reference, so unconditional uniqueness would reject every second `created` row.
45–48
The section's core principle, enforced. A code path holding a browser callback cannot reach `succeeded`, whatever it claims, because it cannot present `source="provider"`.

Why this works: Money stays exact, provider events have a stable join key, retries do not multiply payments, and no browser-originated code path can mark a payment successful.

Granting on the success redirect

Wrong

python
def payment_success(request, order_id):
    order = Order.objects.get(pk=order_id)
    order.status = "paid"              # the browser said so
    order.save()
    grant_access(order)                # anyone can request this URL

Better

python
def payment_success(request, order_id):
    order = get_object_or_404(Order, pk=order_id, user=request.user)
    # Read only. The webhook grants; this page reports.
    return render(request, "checkout/success.html", {"order": order})

What you see: Orders are marked paid with no corresponding money, discovered when the settlement report is reconciled — or not discovered at all, if nobody reconciles.

Why: The redirect travels through the customer's browser, so the request is entirely under their control: it can be replayed, shared, bookmarked, or constructed by hand with a guessed order id. Treating it as proof means the "did they pay?" decision is made by the party with the strongest incentive to lie. Even honestly, it is unreliable — a customer who closes the tab after authorising never reaches the page, so a system that grants only there also fails to grant for legitimate payments. The webhook is both the secure path and the complete one.

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)

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

Together

python
def success_page(request, payment_id):
    payment = get_object_or_404(Payment, pk=payment_id, order__user=request.user)
    # Reads state. Grants nothing. May legitimately say "still confirming".
    return render(request, "checkout/success.html", {"payment": payment})

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

Together

python
PROVIDER_ONLY = {"succeeded", "failed", "disputed"}
# a transition into one of these requires a verified provider event

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

See also: verifying a webhook and its raw body · duplicates ordering and transaction boundaries · valid and invalid transitions

Advertisement

Proving the event came from the provider

Signature, freshness and constant-time comparison — over the raw bytes, before anything parses them.

Verifying a webhook, and why the raw body matters

coreadvanced

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.

Think of it as

Three properties have to hold before you trust an inbound event, and each one blocks a different attack. Authenticity is the signature: it proves the request came from someone holding the shared secret, which is the provider. Integrity comes with it, because the signature covers the payload — one flipped character in an amount invalidates it. Freshness is the timestamp, and it is the one people leave out: a signature stays valid forever, so an attacker who captures a legitimate "payment succeeded" event can replay it next week and, without a freshness check, it verifies perfectly. Providers include a timestamp in the signed material precisely so you can reject anything outside a tolerance window. Getting the bytes right is the mechanical part, and it is where most first attempts fail. The signature is computed over what was transmitted, so any round trip through a parser destroys it: `json.loads` followed by `json.dumps` is not the identity function, and neither is Django's form parsing. `request.body` gives you the raw bytestring, and the constraint documented in Django's reference is that reading the stream first — with `read()` or `readline()` — makes accessing `body` afterwards raise `RawPostDataException`. So the discipline is simply: `body` first, always, before parsing and before any middleware or decorator can touch the stream. The comparison itself has one requirement worth internalising, which is that `==` on the two digests leaks timing information — it returns as soon as bytes differ, so how long it takes reveals how many leading bytes matched, and that is enough to reconstruct a signature over many requests. `hmac.compare_digest` compares in constant time and exists for this. Finally, two Django-specific pieces of plumbing. The endpoint needs `csrf_exempt`, because a provider has no CSRF token and cannot get one — this is safe precisely because the signature replaces CSRF as the origin proof, so an endpoint that is exempt *and* unverified is the dangerous combination. And the secret belongs in the environment, never the repository, with support for two valid secrets at once so rotation does not require an outage.

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

What we're doing: A webhook endpoint that proves origin, rejects replays, compares safely, supports secret rotation, and acknowledges before doing any work.

payments/webhooks.pypython
import hashlib, hmac, json, time
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

TOLERANCE_SECONDS = 300


@csrf_exempt          # a provider has no CSRF token; the signature replaces it
@require_POST
def provider_webhook(request):
    # 1. RAW BYTES FIRST. Django's docs: accessing body after reading the
    #    stream produces RawPostDataException — and any parse changes them.
    raw = request.body

    signature = request.headers.get("X-Signature", "")
    timestamp = request.headers.get("X-Timestamp", "")

    # 2. Freshness. A signature stays valid forever; this is what stops a
    #    captured event being replayed next month.
    try:
        if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
            return HttpResponseForbidden("stale timestamp")
    except ValueError:
        return HttpResponseForbidden("bad timestamp")

    # 3. Constant-time comparison, against every currently valid secret so
    #    that rotation does not need an outage.
    signed_payload = timestamp.encode() + b"." + raw
    if not any(
        hmac.compare_digest(
            hmac.new(secret, signed_payload, hashlib.sha256).hexdigest(), signature
        )
        for secret in settings.WEBHOOK_SECRETS          # [current, previous]
    ):
        return HttpResponseForbidden("bad signature")

    # 4. Only now is this data trustworthy enough to parse.
    event = json.loads(raw)

    # 5. Persist and acknowledge FAST. Processing happens in a worker, so a
    #    slow handler cannot cause provider timeouts and a retry storm.
    row, created = WebhookEvent.objects.get_or_create(
        provider="acme", event_id=event["id"],
        defaults={"payload": event, "received_at": timezone.now()},
    )
    if created:
        transaction.on_commit(lambda: process_webhook_event.delay(row.id))

    return HttpResponse(status=200)
11–13
The single most important line in the file, and the one most often written third. Any parse — `json.loads`, `request.POST`, a DRF serializer — either changes the bytes or consumes the stream.
19–24
The check most implementations omit. Without it the endpoint accepts a correctly signed event of any age, so anyone who ever captures one can replay it indefinitely.
28–34
Iterating over a list of secrets is what makes rotation an ordinary deploy: add the new secret, switch the provider, remove the old one — with no window where valid events are rejected.
30
`compare_digest` rather than `==`. The difference only matters against an attacker measuring response times, which is exactly the attacker this endpoint has.
42–47
Acknowledge fast, work later. Providers retry non-2xx and time out slow handlers, so heavy work inline turns one slow database query into a flood of duplicate deliveries.

Why this works: A forged or replayed event is rejected before parsing, the comparison leaks nothing, secrets rotate without downtime, and the provider always gets a fast 200.

Verifying against a re-serialised payload

Wrong

python
event = json.loads(request.body)
expected = hmac.new(secret, json.dumps(event).encode(), sha256).hexdigest()
# json.dumps is not the inverse of json.loads: key order and spacing differ

Better

python
raw = request.body
expected = hmac.new(secret, raw, sha256).hexdigest()

What you see: Every webhook fails verification, or — far worse — verification is quietly relaxed to make them pass, and the endpoint ends up accepting anything.

Why: A signature covers the exact bytes transmitted. `json.loads` discards formatting entirely, and `json.dumps` re-creates it with its own conventions: different key order, different separators, different unicode escaping. The reconstructed bytes are semantically identical and cryptographically unrelated. The dangerous part is the second-order failure — when the check fails for everything, the pressure is to skip it rather than to find the cause, and an unverified `csrf_exempt` endpoint that moves money is the worst combination in this section.

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.

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

Together

python
if abs(time.time() - int(timestamp)) > 300:
    return HttpResponseForbidden("stale")     # 5-minute tolerance

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

Together

python
raw = request.body            # FIRST — before parsing, always
verify(raw, request.headers["X-Signature"])
event = json.loads(raw)       # only now

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

See also: duplicates ordering and transaction boundaries · reconciliation retries and audit · testing webhooks and the five integration surfaces

Advertisement

At-least-once, and unordered

Duplicates and late events are normal conditions, not provider bugs.

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

coreadvanced

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.

Think of it as

Stop treating the stream of webhooks as a sequence and start treating each one as an independent assertion about state, which may be stale and may already have been applied. That reframing produces both defences. Deduplication answers "have I already applied this?", and the reliable way to answer it is a unique constraint on `(provider, event_id)` — not an `.exists()` check, which has a gap between the read and the write that two concurrent deliveries fit through neatly. Django's own documentation notes the race in `get_or_create`, and the resolution is the same: the constraint is what makes it true, the ORM call is just how you express it. Ordering is the second and subtler one. Even perfectly deduplicated events can arrive in the wrong order, so a handler that applies whatever it receives will happily set a refunded payment back to succeeded. The fix is not to sort — you cannot, because you do not know what has not arrived yet — but to make application *monotonic*: rank the states, and ignore any event that would move the payment backwards or sideways. That single rule handles late arrivals, duplicate deliveries and out-of-order retries with one mechanism. Where a provider supplies a sequence number or a version on the object, prefer that over your own ranking, since it reflects their view of the ordering rather than your guess at it. The transaction boundary then ties both together. Everything that must be true together — marking the event processed, moving the payment, writing the audit row, updating the order — belongs in one `atomic()` block, because an event marked processed whose effects were rolled back is an event that will never be applied. Everything irreversible — the confirmation email, the fulfilment task, the outbound notification — belongs in `on_commit`, for the reason that recurs throughout this batch: a message sent inside a transaction that fails cannot be recalled, and a task enqueued there can outrun its own data. And the acknowledgement itself sits outside all of it: return 200 as soon as the event is stored, so a slow handler cannot cause the retry storm that makes duplicates worse.

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

What we're doing: Process a webhook event exactly once, ignore anything stale, and keep the durable effects in one transaction with the irreversible ones after it.

payments/tasks.pypython
STATUS_RANK = {
    "created": 0, "processing": 1, "requires_action": 1,
    "succeeded": 2, "failed": 2, "refunded": 3, "disputed": 4,
}


@shared_task(bind=True, max_retries=5, retry_backoff=True, retry_jitter=True)
def process_webhook_event(self, event_row_id):
    row = WebhookEvent.objects.get(pk=event_row_id)

    if row.processed_at is not None:
        return                       # already applied — a duplicate task run

    payload = row.payload
    payment = Payment.objects.filter(
        provider=row.provider, provider_reference=payload["payment_reference"]
    ).first()

    if payment is None:
        # The event may have overtaken our own record of the attempt. Retry
        # with backoff rather than dropping it — but bound the attempts.
        raise self.retry(countdown=30)

    with transaction.atomic():
        # Lock, then re-read: two events for the same payment can be in
        # flight at once, and this is the same race as any state machine.
        payment = Payment.objects.select_for_update().get(pk=payment.pk)

        incoming = payload["status"]
        if STATUS_RANK[incoming] <= STATUS_RANK[payment.status]:
            # Stale or already applied. Mark the event handled and stop —
            # it is not an error, and it must not be retried.
            row.processed_at = timezone.now()
            row.outcome = "ignored_stale"
            row.save(update_fields=["processed_at", "outcome"])
            return

        payment.transition_to(incoming, source="provider", event=row)

        PaymentEvent.objects.create(payment=payment, event=row, to_state=incoming)
        row.processed_at = timezone.now()
        row.outcome = "applied"
        row.save(update_fields=["processed_at", "outcome"])

        # Irreversible work, only after the commit.
        if incoming == "succeeded":
            transaction.on_commit(lambda: fulfil_order.delay(payment.order_id))
            transaction.on_commit(lambda: send_receipt.delay(payment.id))
11–12
A second guard inside the worker. The unique constraint stops duplicate *rows*; this stops duplicate *processing* when the task itself is delivered twice, which is a separate at-least-once channel.
19–22
A missing payment is retried rather than dropped, because events routinely overtake the local write that created the attempt. The retry is capped so a genuinely unknown reference does not loop forever.
24–27
The same locking discipline as the state-machine section: two events for one payment can be processed concurrently, and without the lock both read the old status.
29–35
The monotonicity rule, and the important detail is that a stale event is marked processed and *not* retried. Treating it as a failure would retry it forever and fill the error tracker with correct behaviour.
44–47
Fulfilment and the receipt are deferred past the commit. Sending a receipt inside a transaction that then rolls back tells the customer about money that did not move.

Why this works: A duplicate event changes nothing, a late event cannot undo a refund, concurrent events for one payment are serialised, and nothing leaves the system until the state change is durable.

Deduplicating with `.exists()`

Wrong

python
if WebhookEvent.objects.filter(event_id=event["id"]).exists():
    return HttpResponse(status=200)
WebhookEvent.objects.create(event_id=event["id"], payload=event)
# two concurrent deliveries both pass the check, and both create a row

Better

python
row, created = WebhookEvent.objects.get_or_create(
    provider="acme", event_id=event["id"], defaults={"payload": event},
)   # backed by UniqueConstraint(fields=["provider", "event_id"])

What you see: A payment is fulfilled twice, or a customer receives two receipts, only under load — and the code visibly checks for duplicates, so the deduplication is assumed to be working.

Why: A check-then-write is two statements with a gap, and providers retry aggressively enough that two deliveries of the same event genuinely do land at once. Both pass the `.exists()` check, both insert, and both go on to apply the effects. Only the database can settle it: a unique constraint makes the second insert fail, which is what `get_or_create` relies on — Django's own documentation notes the race and points at the constraint as the guarantee. Without the constraint, `get_or_create` has exactly the same gap.

Three deliveries of two events — one duplicate, one out of order
Provider
Webhook view
Worker
Database
  1. 1. evt_A "succeeded"
  2. 2. INSERT WebhookEvent(evt_A)unique on (provider, event_id)
  3. 3. 200 — in milliseconds
  4. 4. apply: processing → succeeded
  5. 5. evt_B "refunded"
  6. 6. apply: succeeded → refunded
  7. 7. evt_A again (their retry timed out)at-least-once: this is normal
  8. 8. INSERT evt_A → IntegrityErrorcaught; already recorded
  9. 9. 200 — do not 500 on a duplicatea 500 here just causes more retries
  10. 10. rank("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)

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

Together

python
RANK = {"created": 0, "processing": 1, "requires_action": 1,
        "succeeded": 2, "failed": 2, "refunded": 3, "disputed": 4}
if RANK[incoming] <= RANK[payment.status]:
    return                      # stale or duplicate: ignore, and ack

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

See also: verifying a webhook and its raw body · reconciliation retries and audit · delivery guarantees and ordering

Advertisement

The events that never arrive

Reconciliation as the correct path, retry classification, and the audit money requires.

Reconciliation, retries, and the audit trail money requires

standardadvanced

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.

Think of it as

Treat webhooks as the fast path and reconciliation as the correct one. That ordering matters: if you think of webhooks as the mechanism and reconciliation as a nice extra, you will not build the second, and the system will be silently wrong whenever the first fails — which it does, routinely and without telling you. A payment stuck in `processing` because its `succeeded` webhook never arrived looks exactly like a payment genuinely still processing, and no amount of care in the handler distinguishes them. Only asking the provider does. So the reconciliation job selects payments that have been in a non-terminal state longer than they should be, fetches each one's current status from the provider, and applies the difference through the same transition path a webhook would use — which is the important design point, because a second code path that writes payment state is a second place for the rules to diverge. Run it on a schedule, alert when it finds anything, and treat a rising count as a signal that webhook delivery is degraded. The daily settlement file is the stronger version of the same idea: it is the provider's own ledger, so comparing totals against it catches the failures that a per-payment status check cannot, such as a payment you never recorded at all. Retry handling here follows the classification this batch has used throughout, with one payment-specific edge: a request that timed out is *ambiguous*, not failed. You do not know whether the charge went through, so retrying blindly risks charging twice — which is what the provider's idempotency key is for. Send the same key on the retry and the provider returns the original result rather than creating a second charge. Audit is the third, and money raises the stakes: a chargeback can arrive months later, and the question will be what you knew and when. Record every state change with the provider reference, the amount, the actor and the event that caused it, keep it append-only, and keep it long enough to outlive the dispute window.

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

What we're doing: An hourly reconciliation command that finds stuck payments, asks the provider, applies differences through the normal transition path, and reports a count worth alerting on.

payments/management/commands/reconcile_payments.pypython
class Command(BaseCommand):
    help = "Find payments the provider has resolved but we have not heard about."

    def add_arguments(self, parser):
        parser.add_argument("--stale-minutes", type=int, default=30)
        parser.add_argument("--apply", action="store_true")

    def handle(self, *args, **options):
        cutoff = timezone.now() - timedelta(minutes=options["stale_minutes"])

        # A payment stuck non-terminal past its window is either genuinely
        # in progress or missing a webhook. Only the provider can say which.
        stale = Payment.objects.filter(
            status__in=[Payment.Status.PROCESSING, Payment.Status.REQUIRES_ACTION],
            updated_at__lt=cutoff,
        ).exclude(provider_reference="")

        drift = 0
        for payment in stale.iterator(chunk_size=200):
            remote = provider_client.fetch(payment.provider, payment.provider_reference)

            if remote.status == payment.status:
                continue                       # genuinely still in progress

            drift += 1
            self.stdout.write(
                f"{payment.provider_reference}: ours={payment.status} "
                f"theirs={remote.status}"
            )

            if options["apply"]:
                # The SAME transition path a webhook uses. A second writer of
                # payment state is a second place for the rules to diverge.
                payment.transition_to(remote.status, source="provider",
                                      event=remote.as_event())

        # A rising count is a webhook-delivery alarm, not just a repair job.
        self.stderr.write(
            self.style.WARNING(f"{drift} payments out of sync")
            if drift else self.style.SUCCESS("in sync")
        )
        if drift:
            raise CommandError(f"{drift} payments reconciled", returncode=3)
12–16
Selecting by age and non-terminal status is the whole query. Excluding empty references skips payments that never reached the provider at all — those are a different problem, caught by the settlement file.
22–23
Agreement is the common case and costs one API call. The job is cheap precisely because it only looks at payments that are already overdue.
31–34
Routing the fix through `transition_to` means reconciliation inherits the state machine, the audit row and the outbox event automatically — and cannot invent a transition the webhook path would have rejected.
41–42
A distinct exit code, so the scheduler can alert on "found drift" separately from "the job crashed". Drift is not a failure of this command; it is a finding.

Why this works: Lost webhooks are found within the hour and repaired through the one code path that owns payment state, and a rising drift count surfaces a degrading endpoint before customers report 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

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

Together

python
stale = Payment.objects.filter(
    status__in=["processing", "requires_action"],
    updated_at__lt=timezone.now() - timedelta(minutes=30),
)

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

Together

python
provider.charge(amount, idempotency_key=str(attempt.uuid))
# same key on every retry: the provider returns the original result

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

See also: duplicates ordering and transaction boundaries · the payment state model · what commands are for

Advertisement