Skip to content

Django class-based callback view

A copy-pasteable Django class-based view that receives a TBC callback, confirms the payment with TBC, and schedules idempotent fulfilment.

pip install "tbc-payments[django]"

The view

import os

from django.db import transaction
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from tbc_payments import TBCCallbackError, TBCClient
from tbc_payments.integrations.django import payment_id_from_request

from .models import Order
from .tasks import fulfil_order


@method_decorator(csrf_exempt, name="dispatch")
class TBCCallbackView(View):
    http_method_names = ["post"]

    def post(self, request: HttpRequest) -> HttpResponse:
        try:
            payment_id = payment_id_from_request(request)
        except TBCCallbackError:
            return HttpResponseBadRequest()

        # The callback body is not proof of payment: ask TBC for the real status.
        with TBCClient(
            os.environ["TBC_API_KEY"],
            os.environ["TBC_CLIENT_ID"],
            os.environ["TBC_CLIENT_SECRET"],
        ) as tbc:
            payment = tbc.get_payment(payment_id)

        if payment.status == "Succeeded":
            with transaction.atomic():
                # Only the first callback for this payment flips the row.
                marked = Order.objects.filter(
                    tbc_pay_id=payment.pay_id, paid_at__isnull=True
                ).update(paid_at=timezone.now())
                if marked:
                    transaction.on_commit(lambda: fulfil_order.delay(payment.pay_id))

        return HttpResponse(status=200)

Route it in your URL configuration:

from django.urls import path

from .views import TBCCallbackView

urlpatterns = [
    path("webhooks/tbc/", TBCCallbackView.as_view()),
]

The example assumes an Order model that stores the TBC pay_id when you create the payment, and a background task fulfil_order (Celery shown; any task queue works):

class Order(models.Model):
    tbc_pay_id = models.CharField(max_length=64, unique=True)
    paid_at = models.DateTimeField(null=True, blank=True)

How it works

CSRF exemption. TBC cannot send a Django CSRF token, so the view is wrapped in csrf_exempt. Without it, CsrfViewMiddleware rejects every callback with HTTP 403. http_method_names = ["post"] makes Django answer other methods with HTTP 405.

Parsing. payment_id_from_request reads the JSON body and returns the PaymentId. It raises TBCCallbackError for malformed input, which the view turns into HTTP 400.

Verification. The callback only says that something happened to a payment. It is not signed, so anyone can post one. The view therefore fetches the payment with get_payment and acts only on the status that TBC returns.

Idempotent fulfilment. TBC can deliver the same callback more than once, and two deliveries can arrive at the same time. The conditional UPDATE ... WHERE paid_at IS NULL runs atomically in the database, so exactly one request gets marked == 1 and schedules fulfilment. Repeated callbacks update zero rows and do nothing. Keep tbc_pay_id unique so one payment can never match two orders. A task queue may still retry fulfil_order, so make the task itself idempotent as well.

Prompt HTTP 200. The view does one TBC lookup and one database update, then returns HTTP 200. Slow work such as emails or stock changes runs in fulfil_order, outside the request. transaction.on_commit schedules that task only after the update is committed, so a rolled-back transaction never fulfils an order.

Errors. If get_payment raises a TBCError (network failure, TBC outage), the view lets Django return HTTP 500 so the failure is visible in your logs and monitoring. Do not mark the order as paid in that case; the payment can still be reconciled later with get_payment.

See Callbacks and webhooks for the callback payload and Framework integrations for the function-based variant.