TRENDING
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 28, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A mountain gorilla looks at the camera from green forest undergrowth
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 242 Posts
Home/Learning Hub/How to Handle Split Payment Webhooks in Django Without Losing Data
Learning Hub

How to Handle Split Payment Webhooks in Django Without Losing Data

A hands-on Django tutorial that builds a deposit-and-balance payment workflow with referral payouts, then reproduces a duplicate-webhook crash and a silent lost-update race on purpose to show exactly...

August 25, 2026 18 Min Read
53

A lot of real products do not charge a customer once and call it done. A course platform takes a deposit to reserve a seat and the rest of the tuition closer to the start date. A consulting shop takes a retainer now and a final invoice at delivery. Both of those examples often also owe a cut to whoever referred the customer, but only once the money has actually arrived. All three of those facts (a deposit, a balance, and a referral payout) are usually driven by webhooks: HTTP callbacks that a payment gateway (Stripe, Paystack, and most others work this way) sends to your server whenever something happens on its side.

Table Of Content

  • Prerequisites
  • Step 1: Set Up the Django Project
  • Step 2: Design the Data Model
  • Why Explicit Stage Flags Instead of One “Paid” Boolean
  • Why gateway_reference Is Unique
  • Step 3: Write a Naive Webhook Handler
  • Step 4: Reproduce the First Bug: Gateway Retries
  • Step 5: Fix Duplicate Delivery With get_or_create()
  • Step 6: A Sneakier Bug: Two Real Payments Racing Each Other
  • Reproducing the Race With Concurrent Requests
  • Step 7: A Detour Into SQLite Locking
  • Step 8: The Portable Fix: Single-Statement Conditional Updates
  • Step 9: Full End-to-End Walkthrough
  • Common Mistakes and Gotchas
  • How to Verify Everything Works End to End
  • Next Steps

In this tutorial you will build that exact system in Django: an Enrollment that tracks a deposit and a balance separately, a Payment row per transaction, and a ReferralPayout that only exists once a payment has actually succeeded. Then you will break it twice, on purpose, in ways that are extremely common in real payment code and very easy to miss in a code review:

  • A payment gateway retries a webhook delivery (this is normal, documented behavior, not a bug on the gateway’s side), and your naive handler crashes the second time because it tries to insert the same record twice.
  • Two legitimate, different payment events for the same customer arrive close together, and a naive handler silently loses one of them, even though both payments genuinely succeeded.

You will fix both bugs, and along the way you will run into a real SQLite locking quirk that most Django tutorials never mention, verify it against Django’s own documentation, and see exactly why the portable fix does not depend on that quirk at all.

Two terms come up constantly in this post, so let’s define them before using them. A webhook is just an HTTP POST that some other system sends to your server when an event happens; from your server’s point of view, it looks like any other incoming request. Idempotent means “safe to do more than once with no different effect than doing it once.” A race condition is a bug that only appears when two things happen at almost the same time; the specific flavor you will reproduce here is a lost update, where two requests each read the same starting state, each change a different part of it, and the second one to save overwrites the first one’s change because it was working from a stale copy.

Prerequisites

  • Python 3.11 or newer. This tutorial was built and tested on Python 3.13.14.
  • Django. This tutorial was built and tested on Django 6.1, installed fresh with pip install django.
  • Basic familiarity with Django: you should know what a model, a migration, and a view are, even if you have never built a webhook handler before. If you have never touched Django, work through Django’s own tutorial first.
  • A terminal and curl (or any HTTP client) to fire test requests. No real payment gateway account is needed; you will simulate webhook deliveries yourself with plain HTTP requests.
  • The requests Python package (pip install requests) for the concurrency test in Step 6.

Everything in this post, every command, every crash, and every fixed run, was actually executed against a live local server while writing it. Where output is shown, it is the real output, not a description of what should happen.

Step 1: Set Up the Django Project

Create a project directory, install Django, and scaffold a project plus one app:

pip install django
django-admin startproject coreproj .
python manage.py startapp enrollments

Add the new app to INSTALLED_APPS in coreproj/settings.py:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'enrollments',
]

This tutorial uses the default SQLite database, which needs no separate server to install, so you can follow every step exactly as written. Everything you will learn here (the bugs and the fixes) applies just as much to Postgres or MySQL in production; the one place SQLite behaves differently gets its own honest callout in Step 7.

Step 2: Design the Data Model

Open enrollments/models.py and replace its contents with three models:

from django.conf import settings
from django.db import models


class Enrollment(models.Model):
    student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    course_name = models.CharField(max_length=120)
    deposit_paid = models.BooleanField(default=False)
    balance_paid = models.BooleanField(default=False)
    access_unlocked = models.BooleanField(default=False)
    referral_code = models.CharField(max_length=50, blank=True, default="")
    partner_name = models.CharField(max_length=120, blank=True, default="")
    created_at = models.DateTimeField(auto_now_add=True)


class Payment(models.Model):
    STAGE_DEPOSIT = "deposit"
    STAGE_BALANCE = "balance"
    STAGE_CHOICES = [
        (STAGE_DEPOSIT, "Deposit"),
        (STAGE_BALANCE, "Balance"),
    ]

    STATUS_PENDING = "pending"
    STATUS_SUCCEEDED = "succeeded"
    STATUS_FAILED = "failed"
    STATUS_CHOICES = [
        (STATUS_PENDING, "Pending"),
        (STATUS_SUCCEEDED, "Succeeded"),
        (STATUS_FAILED, "Failed"),
    ]

    enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE, related_name="payments")
    stage = models.CharField(max_length=20, choices=STAGE_CHOICES)
    gateway_reference = models.CharField(max_length=120, unique=True)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    discount_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
    net_amount = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)
    finalized_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)


class ReferralPayout(models.Model):
    payment = models.OneToOneField(Payment, on_delete=models.CASCADE, related_name="referral_payout")
    partner_name = models.CharField(max_length=120)
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

Why Explicit Stage Flags Instead of One “Paid” Boolean

It would be simpler to give Enrollment a single is_paid flag, but that throws away information you need. With a deposit and a balance tracked as two separate booleans, you can tell at a glance whether a customer has paid nothing, paid the deposit only, or paid in full, and you can unlock access only when both are true. A single flag cannot represent “paid the deposit but not the balance” at all.

Why gateway_reference Is Unique

The gateway_reference field is meant to hold whatever unique identifier the payment gateway assigns to a specific event (Stripe calls this an event ID, other gateways use different names, but the idea is universal). Marking it unique=True is what makes the fix in Step 5 work: it lets the database itself refuse a second row for an event you have already recorded, instead of you having to remember to check for it everywhere.

Create and apply the migration:

python manage.py makemigrations enrollments
Migrations for 'enrollments':
  enrollments/migrations/0001_initial.py
    + Create model Enrollment
    + Create model Payment
    + Create model ReferralPayout
python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, enrollments, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  ... (built-in Django migrations, trimmed here for length) ...
  Applying enrollments.0001_initial... OK
  Applying sessions.0001_initial... OK

(On Mac or Linux the migration file path will show forward slashes; the content is identical either way.)

Finally, seed one test student and enrollment so you have something to send webhooks against. From a Django shell (python manage.py shell) or a small script:

from django.contrib.auth import get_user_model
from enrollments.models import Enrollment

User = get_user_model()
student = User.objects.create_user(username="priya", email="[email protected]")
enrollment = Enrollment.objects.create(
    student=student,
    course_name="Advanced Django",
    referral_code="LAUNCH10",
    partner_name="DevPartners Inc",
)
print(enrollment.id)  # 1

Step 3: Write a Naive Webhook Handler

Add this to enrollments/views.py. It is deliberately naive; you are about to watch it fail in two different ways before fixing it.

import json
import time
from decimal import Decimal

from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt

from .models import Enrollment, Payment, ReferralPayout

REFERRAL_RATE = Decimal("0.10")


@csrf_exempt
def payment_webhook(request):
    data = json.loads(request.body)
    enrollment = Enrollment.objects.get(pk=data["enrollment_id"])
    stage = data["stage"]
    amount = Decimal(str(data["amount"]))
    discount = Decimal(str(data.get("discount_amount", "0")))
    net = amount - discount

    payment = Payment.objects.create(
        enrollment=enrollment,
        stage=stage,
        gateway_reference=data["gateway_reference"],
        amount=amount,
        discount_amount=discount,
        net_amount=net,
        status=Payment.STATUS_SUCCEEDED,
        finalized_at=timezone.now(),
    )

    if stage == Payment.STAGE_DEPOSIT:
        enrollment.deposit_paid = True
    elif stage == Payment.STAGE_BALANCE:
        enrollment.balance_paid = True

    if enrollment.referral_code:
        payout_amount = (net * REFERRAL_RATE).quantize(Decimal("0.01"))
        ReferralPayout.objects.create(
            payment=payment,
            partner_name=enrollment.partner_name,
            amount=payout_amount,
        )

    if enrollment.deposit_paid and enrollment.balance_paid:
        enrollment.access_unlocked = True

    enrollment.save()

    return JsonResponse({"status": "ok", "payment_id": payment.id})

Wire it up in coreproj/urls.py:

from django.contrib import admin
from django.urls import path

from enrollments import views

urlpatterns = [
    path('admin/', admin.site.urls),
    path('webhook/payment/', views.payment_webhook),
]

Start the dev server:

python manage.py runserver

And fire one webhook by hand to confirm it works:

curl -X POST http://127.0.0.1:8000/webhook/payment/ \
  -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "deposit", "gateway_reference": "evt_001", "amount": "500.00", "discount_amount": "0.00"}'
{"status": "ok", "payment_id": 1}

So far this looks like a perfectly reasonable webhook handler. It has two real bugs in it, and neither one shows up on this first, single request.

Step 4: Reproduce the First Bug: Gateway Retries

Payment gateways do not guarantee they will deliver a webhook exactly once. GitHub’s own webhook documentation, describing the same at-least-once delivery model that payment gateways use, is explicit about this: “If your server goes down, you should redeliver missed webhooks once your server is back up.” Redelivery is a supported, expected feature, not a failure mode you have to guard against only in theory. GitHub goes further and documents that a redelivered event keeps the same delivery identifier as the original: “If you request a redelivery, the X-GitHub-Delivery header will be the same as in the original delivery.” That stable identifier across redeliveries is exactly what gateway_reference is for in this tutorial’s model.

Simulate a retried delivery by sending the exact same payload a second time, same gateway_reference and all:

curl -X POST http://127.0.0.1:8000/webhook/payment/ \
  -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "deposit", "gateway_reference": "evt_001", "amount": "500.00", "discount_amount": "0.00"}'
HTTP/1.1 500 Internal Server Error
...
django.db.utils.IntegrityError: UNIQUE constraint failed: enrollments_payment.gateway_reference

That is a real, captured crash. The unique constraint you added in Step 2 did its job (it stopped a duplicate row from being written), but Payment.objects.create() has no idea that failure is a normal, expected outcome, so it lets the exception bubble all the way up into an unhandled server error. From the gateway’s point of view, your server just returned a 500, which typically means it will retry again later, and keep failing the same way every time. That is a retry storm waiting to happen, caused entirely by not planning for delivery that gateways already tell you to expect.

Step 5: Fix Duplicate Delivery With get_or_create()

Django’s queryset API has a method built almost exactly for this situation. Its own documentation states plainly: “This is meant to prevent duplicate objects from being created when requests are made in parallel, and as a shortcut to boilerplatish code.” There is one condition attached to that guarantee, and it matters enough to quote in full: “This method is atomic assuming that the database enforces uniqueness of the keyword arguments (see unique or unique_together). If the fields used in the keyword arguments do not have a uniqueness constraint, concurrent calls to this method may result in multiple rows with the same parameters being inserted.” This is exactly why gateway_reference got a real database-level unique=True constraint back in Step 2, rather than just being a plain field you promise to check yourself.

Update the view to use get_or_create(), keyed on gateway_reference, wrapped in transaction.atomic() so the payment record and its side effects commit or fail together:

import json
import time
from decimal import Decimal

from django.db import transaction
from django.http import JsonResponse
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt

from .models import Enrollment, Payment, ReferralPayout

REFERRAL_RATE = Decimal("0.10")


@csrf_exempt
def payment_webhook(request):
    data = json.loads(request.body)
    stage = data["stage"]
    amount = Decimal(str(data["amount"]))
    discount = Decimal(str(data.get("discount_amount", "0")))
    net = amount - discount

    with transaction.atomic():
        payment, created = Payment.objects.get_or_create(
            gateway_reference=data["gateway_reference"],
            defaults=dict(
                enrollment_id=data["enrollment_id"],
                stage=stage,
                amount=amount,
                discount_amount=discount,
                net_amount=net,
                status=Payment.STATUS_SUCCEEDED,
                finalized_at=timezone.now(),
            ),
        )

        if not created:
            return JsonResponse({"status": "already processed", "payment_id": payment.id})

        enrollment = Enrollment.objects.get(pk=data["enrollment_id"])

        if stage == Payment.STAGE_DEPOSIT:
            enrollment.deposit_paid = True
        elif stage == Payment.STAGE_BALANCE:
            enrollment.balance_paid = True

        if enrollment.referral_code:
            payout_amount = (net * REFERRAL_RATE).quantize(Decimal("0.01"))
            ReferralPayout.objects.get_or_create(
                payment=payment,
                defaults=dict(
                    partner_name=enrollment.partner_name,
                    amount=payout_amount,
                ),
            )

        if enrollment.deposit_paid and enrollment.balance_paid:
            enrollment.access_unlocked = True

        enrollment.save()

    return JsonResponse({"status": "ok", "payment_id": payment.id})

Restart the server and repeat the exact same retry test from Step 4 against a fresh gateway_reference:

curl -X POST http://127.0.0.1:8000/webhook/payment/ -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "deposit", "gateway_reference": "evt_002", "amount": "500.00", "discount_amount": "0.00"}'
{"status": "ok", "payment_id": 2}
curl -X POST http://127.0.0.1:8000/webhook/payment/ -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "deposit", "gateway_reference": "evt_002", "amount": "500.00", "discount_amount": "0.00"}'
{"status": "already processed", "payment_id": 2}

No crash, no retry storm, and the second delivery is recognized as the same event instead of being treated as a second payment. The first bug is fixed. A second, quieter bug is still sitting in this same view, and it will not show up on any test that fires one request at a time.

Step 6: A Sneakier Bug: Two Real Payments Racing Each Other

The deposit and the balance are two genuinely different payments with two genuinely different gateway_reference values, so the fix from Step 5 does nothing to protect them from each other. Look closely at the view’s own logic: it reads the enrollment, flips one flag on the in-memory Python object, and saves the whole object back. If two webhooks for the same enrollment are being handled at close to the same moment (a customer’s balance payment clearing right as the deposit confirmation is still being processed is not exotic; it can happen any time your webhook endpoint is briefly slow, or a customer pays both parts back to back), each request can read the enrollment before the other one has saved its change. Whichever request saves last wins, and it overwrites the other request’s change with its own stale copy of the row.

This is the textbook definition of a lost update, and to see it reliably instead of only once in a long while, add one line that widens the window where the bug can happen. This delay stands in for the small amount of real work (serializing a response, an audit log write, an external API call) that already happens in most real handlers between reading and saving:

        if stage == Payment.STAGE_DEPOSIT:
            enrollment.deposit_paid = True
        elif stage == Payment.STAGE_BALANCE:
            enrollment.balance_paid = True
        time.sleep(0.5)  # exaggerates a real race window for this demo

Reproducing the Race With Concurrent Requests

Fire the deposit and balance webhooks at the same instant using two threads:

from concurrent.futures import ThreadPoolExecutor
import requests

BASE = "http://127.0.0.1:8000"

def fire(stage, ref, amount):
    payload = {"enrollment_id": 1, "stage": stage, "gateway_reference": ref, "amount": amount, "discount_amount": "0.00"}
    r = requests.post(f"{BASE}/webhook/payment/", json=payload, timeout=10)
    return stage, r.status_code, r.json()

with ThreadPoolExecutor(max_workers=2) as pool:
    a = pool.submit(fire, "deposit", "evt_race_deposit", "500.00")
    b = pool.submit(fire, "balance", "evt_race_balance", "1500.00")
    print(a.result())
    print(b.result())

The Step 5 view wraps the payment creation, the enrollment read, and the enrollment save all inside one transaction.atomic() block, so this is not even a two-line change; the test can be run exactly as the code already stands. Doing so produces a new error, not the lost update you might expect:

django.db.utils.OperationalError: database is locked

Step 7: A Detour Into SQLite Locking

Django’s own SQLite notes explain precisely why, and are worth quoting directly rather than paraphrasing: “SQLite is meant to be a lightweight database, and thus can’t support a high level of concurrency. OperationalError: database is locked errors indicate that your application is experiencing more concurrency than sqlite can handle in default configuration.” The same page also heads off the obvious fix attempt: increasing the connection’s timeout option “will make SQLite wait a bit longer before throwing ‘database is locked’ errors; it won’t really do anything to solve them.” The actual documented fix is a different setting entirely: “SQLite supports three transaction modes: DEFERRED, IMMEDIATE, and EXCLUSIVE. The default is DEFERRED… To make sure your transactions wait until timeout before raising ‘Database is Locked’, change the transaction mode to IMMEDIATE.” In settings.py:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
        'OPTIONS': {
            'timeout': 20,
            'transaction_mode': 'IMMEDIATE',
        },
    }
}

With that setting in place, the crash disappears, and rerunning the exact concurrent test from Step 6 comes back clean:

deposit: HTTP 200 -> {'status': 'ok', 'payment_id': 4}
balance: HTTP 200 -> {'status': 'ok', 'payment_id': 5}
deposit_paid: True | balance_paid: True | access_unlocked: True

It is tempting to declare victory here, and this is exactly the moment where a lot of real debugging goes wrong: a fix that made both symptoms (the crash and the wrong data) go away at once, without you fully understanding the mechanism, is worth being suspicious of rather than trusting.

Here is why. On SQLite, a write lock covers the entire database file, not just the one row being changed. The Step 5 view wraps the enrollment read, the sleep, and the enrollment save all inside the same transaction.atomic() block that also creates the payment. Under IMMEDIATE mode, that single block takes its write lock the moment it begins, so the second request cannot even start reading the enrollment until the first request’s entire block, sleep included, has committed. The two requests never actually interleave; they get fully serialized, back to back, purely as a side effect of SQLite locking the whole file rather than just the row. Postgres and MySQL use row-level locking and allow real concurrency between transactions touching different rows, so they would not hand you that same accidental protection for free.

To check whether IMMEDIATE mode is really fixing the race, or just hiding it, isolate the two mechanisms. Keep get_or_create() protecting the payment record inside its own short atomic() block exactly as before, but move the enrollment update back out to a separate, unprotected read-modify-write, still under the same IMMEDIATE setting:

    with transaction.atomic():
        payment, created = Payment.objects.get_or_create(
            gateway_reference=data["gateway_reference"],
            defaults=dict(...),
        )

    if not created:
        return JsonResponse({"status": "already processed", "payment_id": payment.id})

    enrollment = Enrollment.objects.get(pk=data["enrollment_id"])
    time.sleep(0.5)
    if stage == Payment.STAGE_DEPOSIT:
        enrollment.deposit_paid = True
    elif stage == Payment.STAGE_BALANCE:
        enrollment.balance_paid = True
    # ... referral payout and access_unlocked logic, unchanged ...
    enrollment.save()

Rerun the identical concurrent test against this version, still with transaction_mode: IMMEDIATE active in settings:

deposit: HTTP 200 -> {'status': 'ok', 'payment_id': 6}
balance: HTTP 200 -> {'status': 'ok', 'payment_id': 7}
deposit_paid: False | balance_paid: True | access_unlocked: False

The lost update is back, with the exact same IMMEDIATE setting that appeared to fix it a moment ago. Run it again to be sure this is not a coincidence:

deposit: HTTP 200 -> {'status': 'ok', 'payment_id': 8}
balance: HTTP 200 -> {'status': 'ok', 'payment_id': 9}
deposit_paid: False | balance_paid: True | access_unlocked: False

deposit_paid lost both times here, which comes down to a detail of the test script itself: the deposit request is submitted to the thread pool a fraction of a second before the balance request, so it also tends to finish its sleep and save first, leaving the balance request as the last writer more often than not. That consistent bias is not the point; which flag loses depends on submission order and OS thread scheduling, neither of which your webhook handler controls or should be relying on. This confirms the real mechanism: transaction_mode: IMMEDIATE never fixed the read-modify-write race itself. It only serialized two requests that both happened to be wrapped, start to finish, in the same continuous transaction. The instant the enrollment update moves even slightly outside that one transaction, exactly the kind of refactor that happens constantly in real codebases, the protection disappears with no warning. Django is direct about the fact that the standard portable tool for this situation is not available on SQLite at all. Under a heading that reads, in full, “QuerySet.select_for_update() not supported,” its own SQLite notes state: “SQLite does not support the SELECT … FOR UPDATE syntax.” On Postgres or MySQL, wrapping the enrollment read in Enrollment.objects.select_for_update() inside transaction.atomic() is exactly the right tool for a read-then-decide critical section like this one. On SQLite it is a silent no-op: your code runs without error, but no row is actually locked. That is a second, independent trap for anyone testing locally on SQLite and assuming the same protection would carry over to production.

Step 8: The Portable Fix: Single-Statement Conditional Updates

The fix that does not depend on transaction boundaries, locking granularity, or which database is running underneath is to stop reading the enrollment into Python, changing an attribute, and saving the whole object back, in every version of the view, not just the parts that happen to already be inside one big transaction. Instead, issue a single, targeted UPDATE statement per flag. A single SQL statement is atomic on every relational database by definition; there is no read-then-write gap for another request to land in, because there is no separate read step at all:

@csrf_exempt
def payment_webhook(request):
    data = json.loads(request.body)
    stage = data["stage"]
    amount = Decimal(str(data["amount"]))
    discount = Decimal(str(data.get("discount_amount", "0")))
    net = amount - discount

    with transaction.atomic():
        payment, created = Payment.objects.get_or_create(
            gateway_reference=data["gateway_reference"],
            defaults=dict(
                enrollment_id=data["enrollment_id"],
                stage=stage,
                amount=amount,
                discount_amount=discount,
                net_amount=net,
                status=Payment.STATUS_SUCCEEDED,
                finalized_at=timezone.now(),
            ),
        )

    if not created:
        return JsonResponse({"status": "already processed", "payment_id": payment.id})

    field = "deposit_paid" if stage == Payment.STAGE_DEPOSIT else "balance_paid"
    Enrollment.objects.filter(pk=data["enrollment_id"]).update(**{field: True})

    enrollment = Enrollment.objects.get(pk=data["enrollment_id"])

    if enrollment.referral_code:
        payout_amount = (net * REFERRAL_RATE).quantize(Decimal("0.01"))
        ReferralPayout.objects.get_or_create(
            payment=payment,
            defaults=dict(
                partner_name=enrollment.partner_name,
                amount=payout_amount,
            ),
        )

    if enrollment.deposit_paid and enrollment.balance_paid:
        Enrollment.objects.filter(pk=enrollment.pk, access_unlocked=False).update(access_unlocked=True)

    return JsonResponse({"status": "ok", "payment_id": payment.id})

Enrollment.objects.filter(pk=...).update(deposit_paid=True) compiles to one UPDATE ... SET deposit_paid = 1 WHERE id = ... statement. It never reads the current value of balance_paid into Python at all, so there is nothing stale for it to accidentally write back. Two concurrent requests, one setting deposit_paid and one setting balance_paid, each touch only their own column and cannot clobber each other, regardless of transaction mode, journal mode, or which database engine is running underneath.

Rerun the identical concurrent test from Step 6 against this version:

deposit: HTTP 200 -> {'status': 'ok', 'payment_id': 10}
balance: HTTP 200 -> {'status': 'ok', 'payment_id': 11}
deposit_paid: True | balance_paid: True | access_unlocked: True

Reset and run it again to make sure that was not a lucky pass:

deposit: HTTP 200 -> {'status': 'ok', 'payment_id': 13}
balance: HTTP 200 -> {'status': 'ok', 'payment_id': 12}
deposit_paid: True | balance_paid: True | access_unlocked: True

Both runs on this version came back fully correct, in contrast to Step 7’s read-modify-write version, which lost data on both of its runs under the exact same settings. That consistency, not a single passing run, is what tells you this fix addresses the actual mechanism instead of getting lucky on timing.

Step 9: Full End-to-End Walkthrough

Reset the enrollment to a clean state and walk through the complete, realistic sequence: a deposit, a balance payment with a coupon applied, and then a gateway retry of that same balance webhook, all fired one at a time in the order a real integration would see them.

curl -X POST http://127.0.0.1:8000/webhook/payment/ -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "deposit", "gateway_reference": "evt_happy_deposit_01", "amount": "500.00", "discount_amount": "0.00"}'
{"status": "ok", "payment_id": 14}
curl -X POST http://127.0.0.1:8000/webhook/payment/ -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "balance", "gateway_reference": "evt_happy_balance_01", "amount": "1500.00", "discount_amount": "50.00"}'
{"status": "ok", "payment_id": 15}
curl -X POST http://127.0.0.1:8000/webhook/payment/ -H "Content-Type: application/json" \
  -d '{"enrollment_id": 1, "stage": "balance", "gateway_reference": "evt_happy_balance_01", "amount": "1500.00", "discount_amount": "50.00"}'
{"status": "already processed", "payment_id": 15}

The final, real database state after all three requests:

deposit_paid: True | balance_paid: True | access_unlocked: True

Payment id=14 stage=deposit amount=500.00 discount=0.00 net=500.00 status=succeeded
Payment id=15 stage=balance amount=1500.00 discount=50.00 net=1450.00 status=succeeded

ReferralPayout id=14 payment_id=14 amount=50.00
ReferralPayout id=15 payment_id=15 amount=145.00
total referral payout: 195.00

Every number here is exactly what it should be. The deposit’s referral payout is 10 percent of its full 500.00 (50.00), the balance’s payout is 10 percent of its discounted 1450.00 net amount (145.00), and the retried balance webhook did not create a second payment or a second payout; the referral partner is owed 195.00 total, not 390.00. Tying ReferralPayout to a specific Payment through a OneToOneField, rather than computing a payout fresh from the enrollment’s current state every time a webhook fires, is what keeps a partner’s earnings from being recalculated or double-paid on a retry.

Common Mistakes and Gotchas

  • Assuming a webhook only ever arrives once. It will not. Design the very first version of any webhook handler as if every request might be a retry, because eventually one will be.
  • Letting an insert crash on a duplicate instead of handling it. A unique constraint failing is a normal, expected outcome for a retried webhook, not an exceptional situation; get_or_create() against a genuinely unique field turns it into a clean, checkable branch instead of a 500 response.
  • Reading an object, changing one field, and saving the whole thing back, when two different requests might both be doing that to the same row at once. Every field on the object you did not intend to touch is still at risk of being overwritten with a stale value.
  • Trusting select_for_update() to work the same way everywhere. It is the right tool on Postgres and MySQL, and a documented no-op on SQLite. If your test suite runs against SQLite, a bug this specific can pass every local test and still be live in production.
  • Recomputing a payout from current state instead of tying it to the specific event that earned it. If a payout calculation looks at “has this enrollment paid in full” rather than “did this specific payment succeed,” a retried webhook can trigger a second payout for money that already had one.
  • Testing only one request at a time. Every test in Step 4 and Step 5 passed cleanly with the lost-update bug still fully present in the code, because that bug only exists under concurrency. A green single-request test suite proves nothing about race conditions.

How to Verify Everything Works End to End

  1. Send one webhook, confirm the response is {"status": "ok", ...}, and confirm the matching Payment row exists with status="succeeded".
  2. Resend the exact same payload (same gateway_reference) and confirm the response is now {"status": "already processed", ...}, with no new Payment or ReferralPayout row created.
  3. Fire a deposit and a balance webhook concurrently (the ThreadPoolExecutor script from Step 6) and confirm the enrollment ends up with both deposit_paid and balance_paid set to True, not just one of them. Run it at least twice; a single passing run is not enough evidence for a concurrency fix.
  4. Confirm access_unlocked only ever becomes True after both flags are set, never after just one.
  5. Sum every ReferralPayout tied to one enrollment and confirm the total matches ten percent of the sum of all net_amount values on succeeded payments, no more and no less.

Next Steps

From here, a few directions are worth exploring on your own. Verify that a webhook payload actually came from your payment gateway and was not forged or tampered with in transit; this tutorial’s own earlier post on verifying webhook signatures with HMAC and stopping replay attacks covers exactly that layer, and pairs directly with everything you just built here (that post stops a forged request from being trusted at all; this one stops a genuine, repeated request from corrupting your data). If you move this project to Postgres or MySQL for production, replace the single-statement update pattern in Step 8 with select_for_update() wherever you have a genuine read-then-decide critical section spanning more than one field, since both databases give you real, working row-level locks that SQLite cannot. Finally, try adding a third payment stage, or a partial-refund path, and notice how much of the safety you built here (a unique event reference, single-statement updates, and a payout tied to one specific payment) keeps working without any changes at all.

Tags:

ConcurrencyDjangoPythonSQLiteWebhooks

Share

Rows of small labeled wooden drawers in a library card catalog, a visual analogy for AI memory entries organized and searchable by topic
Previous Post

Anthropic’s Unified Claude Memory Turns Personalization Into an Enterprise Governance Question

Industrial crane lifting hook with a safety latch, a visual metaphor for the Git hook mechanism exploited in this vulnerability
Next Post

CISA Orders Federal Agencies to Patch an Actively Exploited Gitea Flaw by August 28

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Trending
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026