Payments Domain - Webhooks Documentation

Complete webhook integration documentation for payment processor webhooks.

Source: api/nextango/apps/payments/webhooks.py Last Updated: 2026-07-08

There are two webhook receivers. StripeWebhookReceiver (documented below) is the Stripe-specific path, mounted at /payments/webhook/stripe/. UnifiedWebhookView, mounted at /payments/webhook/<processor_type>/, is the processor-agnostic entry point for Square, PayPal, and Custom Bank, it resolves the active PaymentProcessor for the given processor_type and calls that gateway's verify_webhook() before routing the event. Only the Stripe-specific detail below (signature format, event handling, race-condition handling) has been re-verified this pass, see Views for the UnifiedWebhookView summary.

Table of Contents


Overview

The Payments webhooks module handles incoming payment processor webhook events, primarily from Stripe. Webhooks provide asynchronous notification of payment status changes and enable secure, real-time payment processing.

Purpose

Asynchronous Payment Validation: In the Nextango architecture, payments are created immediately by POS systems (Stripe Terminal), and webhooks provide validation and reconciliation.

Not Payment Processing: Webhooks do NOT initiate payment processing. Payments are already succeeded when webhooks arrive. Webhooks validate and audit payments processed by POS.

Architecture

Components:

  • StripeWebhookReceiver: APIView for receiving Stripe webhook events
  • Signature Verification: HMAC-SHA256 validation to prevent spoofing
  • Idempotency: WebhookEvent model to prevent duplicate processing
  • Event Routing: Type-based routing to specialized handlers

Design Principles (webhooks.py:1-13):

"""
Key Features:
- HMAC-SHA256 signature verification
- Idempotency via WebhookEvent model
- Fast response times
- Minimal business logic - delegates to existing services
"""

Security Model

Critical Security Features:

  1. Signature Verification: Every webhook validated with HMAC-SHA256
  2. Replay Attack Prevention: Timestamp validation (5-minute tolerance)
  3. Idempotency: Duplicate event detection prevents double-processing
  4. CSRF Exempt: Webhooks bypass CSRF (signature replaces CSRF protection)
  5. Credential Security: Webhook secret loaded from environment variables

Supported Processors

Stripe has its own dedicated receiver, StripeWebhookReceiver, documented in full below.

Square, PayPal, and Custom Bank route through UnifiedWebhookView at /payments/webhook/<processor_type>/. Each gateway implements its own verify_webhook(): Square verifies an X-Square-Hmacsha256-Signature header; PayPal (Zettle) performs full RSA-SHA256 certificate-based verification per the PayPal v1 scheme (five required PayPal v1 headers, PayPal-Auth-Algo must be SHA256withRSA, cert fetched and validated from PayPal-Cert-Url, signature verified over {transmission_id}|{transmission_time}|{webhook_id}|{crc32(body)}, parse-only passthrough exists only in mock mode); Custom Bank verifies against BANK_GATEWAY_WEBHOOK_SECRET. See Services for the gateway implementation table.


Security Architecture

Defense Layers

Layer 1: HTTPS/TLS

  • All webhook traffic over HTTPS
  • TLS 1.2+ required
  • Valid SSL certificate verification

Layer 2: Signature Verification

  • HMAC-SHA256 signature on every request
  • Webhook secret shared only between Stripe and server
  • Signature calculated over raw request body

Layer 3: Replay Attack Prevention

  • Timestamp included in signature
  • 5-minute tolerance window
  • Old webhooks rejected

Layer 4: Idempotency

  • WebhookEvent.event_id uniqueness constraint
  • Status tracking (pending, processed, failed)
  • Duplicate detection before processing

Layer 5: Amount Validation

  • Webhook amount compared to database payment amount
  • Mismatches logged and flagged

Threat Model

Threats Mitigated:

1. Webhook Spoofing (attacker sends fake payment webhook)

  • Mitigated by: HMAC signature verification
  • Attack: POST fake webhook with payment_intent.succeeded
  • Defense: Invalid signature → 401 Unauthorized

2. Replay Attacks (attacker replays legitimate old webhook)

  • Mitigated by: Timestamp validation + idempotency
  • Attack: Capture webhook from 10 minutes ago, replay
  • Defense: Timestamp > 5 min → rejected, OR event_id already processed

3. Amount Tampering (attacker modifies amount in webhook)

  • Mitigated by: Signature covers entire payload
  • Attack: Change $10.00 webhook to $1000.00
  • Defense: Modified payload invalidates signature

4. Race Conditions (webhook arrives before/after POS request)

  • Mitigated by: Payment lookup by processor_transaction_id
  • Defense: Graceful handling - webhook stored for audit even if payment not yet created

Threats NOT Mitigated:

Compromised Webhook Secret:

  • If: Webhook secret leaked (e.g., committed to git)
  • Impact: Attacker can forge valid webhooks
  • Prevention: Secret in environment variables only, rotate immediately if compromised

Webhook Receiver

StripeWebhookReceiver

Source: webhooks.py:34-316

URL Pattern: /payments/webhook/stripe/ (configured in urls.py)

HTTP Methods: GET, POST

Authentication: None (signature verification replaces authentication)

CSRF Protection: Disabled (webhooks can't include CSRF tokens)

Configuration

@method_decorator(csrf_exempt, name='dispatch')
class StripeWebhookReceiver(APIView):
    """
    Secure webhook receiver for Stripe payment events.

    Responsibilities:
    1. Verify Stripe HMAC-SHA256 signature
    2. Check idempotency via WebhookEvent model
    3. Update payment status and link to line items
    4. Mark transaction as completed if fully paid
    5. Return fast 200 response

    Note: Inventory deduction happens automatically via signals
    when transaction status changes to COMPLETED.
    """

    authentication_classes = []  # Webhooks use signature verification
    permission_classes = []      # Webhooks use signature verification

Why CSRF Exempt (webhooks.py:34):

  • Webhooks originate from external systems (Stripe servers)
  • External systems cannot include CSRF tokens
  • Signature verification provides stronger security than CSRF

Why No Authentication:

  • DRF authentication expects user tokens
  • Webhooks authenticate via HMAC signature
  • Signature verification more secure than token auth for webhooks

GET Endpoint

Source: webhooks.py:53-69

Purpose: Test webhook URL accessibility (Stripe setup verification).

Request:

GET /payments/webhook/stripe/

Response (200 OK):

{
  "status": "ok",
  "message": "Stripe webhook endpoint is accessible",
  "endpoint": "/payments/webhook/stripe/",
  "methods": ["GET", "POST"]
}

Use Case: When configuring Stripe webhooks in Stripe Dashboard, Stripe tests URL accessibility with GET request.

Logging (webhooks.py:57-59):

logger.info("=== STRIPE WEBHOOK GET REQUEST ===")
logger.info(f"Request path: {request.path}")
logger.info("Stripe webhook endpoint is accessible")

POST Endpoint

Source: webhooks.py:71-184

Purpose: Receive and process Stripe webhook events.

Request:

POST /payments/webhook/stripe/
Content-Type: application/json
Stripe-Signature: t=1700000000,v1=abc123...

{
  "id": "evt_1234567890",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_1234567890",
      "amount_received": 12550,
      ...
    }
  }
}

Processing Flow:

Step 1: Parse Payload (webhooks.py:81-89)

try:
    webhook_data = json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
    logger.error(f"Failed to parse webhook JSON: {e}")
    return Response(
        {'error': 'Invalid JSON payload'},
        status=status.HTTP_400_BAD_REQUEST
    )

Error (400):

{
  "error": "Invalid JSON payload"
}

Step 2: Verify Signature (webhooks.py:91-97)

if not self._is_signature_valid(request):
    logger.warning("Stripe webhook signature verification failed")
    return Response(
        {'error': 'Invalid webhook signature'},
        status=status.HTTP_401_UNAUTHORIZED
    )

Security: See Security Verification section for details.

Error (401):

{
  "error": "Invalid webhook signature"
}

Step 3: Extract Event Info (webhooks.py:99-110)

event_id = webhook_data.get('id')
event_type = webhook_data.get('type')

if not event_id or not event_type:
    logger.error(f"Missing event ID or type in webhook: {webhook_data}")
    return Response(
        {'error': 'Missing event ID or type'},
        status=status.HTTP_400_BAD_REQUEST
    )

logger.info(f"Verified Stripe webhook: {event_type} ({event_id})")

Validation: Both id and type fields required.

Error (400):

{
  "error": "Missing event ID or type"
}

Step 4: Idempotency Check (webhooks.py:112-144)

See Idempotency Implementation section for details.

Duplicate Response (200):

{
  "status": "duplicate",
  "message": "Webhook already processed",
  "event_id": "evt_1234567890"
}

Processing Response (200):

{
  "status": "processing",
  "message": "Webhook is currently being processed",
  "event_id": "evt_1234567890"
}

Step 5: Process Event (webhooks.py:146-166)

try:
    result = self._process_webhook_event(webhook_event, webhook_data)

    # Mark as processed
    webhook_event.status = 'processed'
    webhook_event.processed_at = timezone.now()
    webhook_event.save(update_fields=['status', 'processed_at'])

    logger.info(f"Successfully processed Stripe webhook: {event_type} ({event_id})")

    return Response(
        {
            'status': 'success',
            'message': 'Webhook processed successfully',
            'event_id': event_id,
            'event_type': event_type,
            'result': result
        },
        status=status.HTTP_200_OK
    )

Success Response (200):

{
  "status": "success",
  "message": "Webhook processed successfully",
  "event_id": "evt_1234567890",
  "event_type": "payment_intent.succeeded",
  "result": {
    "success": true,
    "payment_id": "payment-uuid",
    "validation": "confirmed",
    "amount_match": true
  }
}

Step 6: Error Handling (webhooks.py:168-184)

except Exception as e:
    logger.error(f"Failed to process Stripe webhook {event_id}: {e}", exc_info=True)

    # Mark as failed
    webhook_event.status = 'failed'
    webhook_event.error_message = str(e)
    webhook_event.save(update_fields=['status', 'error_message'])

    return Response(
        {
            'status': 'error',
            'message': 'Failed to process webhook',
            'event_id': event_id,
            'error': str(e)
        },
        status=status.HTTP_500_INTERNAL_SERVER_ERROR
    )

Error Response (500):

{
  "status": "error",
  "message": "Failed to process webhook",
  "event_id": "evt_1234567890",
  "error": "TransactionPayment matching query does not exist"
}

Important: Webhook status marked as 'failed' in database for manual review.


Security Verification

Signature Validation

Source: webhooks.py:186-244

Purpose: Verify webhook authenticity using HMAC-SHA256 signature.

Stripe Signature Format:

Stripe-Signature: t=1700000000,v1=abc123def456...

Components:

  • t: Unix timestamp of webhook creation
  • v1: HMAC-SHA256 signature hash

Verification Process:

Step 1: Load Webhook Secret

webhook_secret = getattr(settings, 'STRIPE_WEBHOOK_SECRET', None)
if not webhook_secret:
    logger.error("STRIPE_WEBHOOK_SECRET is not configured")
    return False

Security: Secret loaded from environment variables, NEVER from database.

Configuration:

# .env file (NOT committed to git)
STRIPE_WEBHOOK_SECRET=whsec_abc123def456...

# Django settings.py
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')

Step 2: Extract Signature Header

signature_header = request.headers.get('Stripe-Signature')
if not signature_header:
    logger.warning("Missing 'Stripe-Signature' header")
    return False

Header Format: t=timestamp,v1=signature_hash

Step 3: Parse Signature Components

try:
    sig_map = {
        key_value.split('=')[0]: key_value.split('=')[1]
        for key_value in signature_header.split(',')
    }
    timestamp_str = sig_map.get('t')
    signature = sig_map.get('v1')

    if not timestamp_str or not signature:
        raise ValueError("Missing timestamp or signature in header")

    timestamp = int(timestamp_str)
except (ValueError, IndexError) as e:
    logger.error(f"Invalid signature header format: {signature_header} - {e}")
    return False

Example Parsing:

Input: "t=1700000000,v1=abc123"
Output:
  timestamp_str = "1700000000"
  signature = "abc123"
  timestamp = 1700000000

Step 4: Replay Attack Prevention

Source: webhooks.py:223-226

# Prevent replay attacks (5-minute tolerance)
if abs(time.time() - timestamp) > 300:
    logger.warning("Webhook timestamp validation failed (possible replay attack)")
    return False

Logic:

  • Current time: time.time() (e.g., 1700001000)
  • Webhook timestamp: timestamp (e.g., 1700000000)
  • Difference: 1000 seconds = 16.6 minutes
  • 1000 > 300 (5 minutes) → REJECT

Why 5 Minutes:

  • Network latency tolerance
  • Clock skew between Stripe and server
  • Balance between security and reliability

Attack Scenario Prevented:

  1. Attacker captures legitimate webhook at time T
  2. Attacker waits 10 minutes
  3. Attacker replays webhook at time T+10min
  4. Timestamp validation fails (10 min > 5 min tolerance)
  5. Webhook rejected

Step 5: Calculate Expected Signature

Source: webhooks.py:228-236

# Construct the signed payload: timestamp_string.raw_body
signed_payload = f"{timestamp_str}.".encode('utf-8') + request.body

# Calculate expected signature
expected_signature = hmac.new(
    webhook_secret.encode('utf-8'),
    signed_payload,
    hashlib.sha256
).hexdigest()

Signed Payload Format: {timestamp}.{raw_json_body}

Example:

timestamp_str = "1700000000"
request.body = b'{"id":"evt_123","type":"payment_intent.succeeded",...}'

signed_payload = b'1700000000.{"id":"evt_123","type":"payment_intent.succeeded",...}'

HMAC Calculation:

secret = "whsec_abc123"
payload = "1700000000.{...json...}"

signature = HMAC-SHA256(secret, payload)
# Output: "def456789abc..."

Step 6: Secure Signature Comparison

Source: webhooks.py:238-244

logger.debug("--- STRIPE SIGNATURE VERIFICATION ---")
logger.debug(f"Timestamp: {timestamp_str}")
logger.debug(f"Signature from header: {signature}")
logger.debug(f"Expected signature: {expected_signature}")

# Securely compare the signatures
return hmac.compare_digest(signature, expected_signature)

Security - Timing Attack Prevention:

INSECURE:

return signature == expected_signature  # Timing attack vulnerable

Why Insecure: String comparison stops at first mismatched character. Attacker can measure response time to deduce signature character-by-character.

SECURE:

return hmac.compare_digest(signature, expected_signature)  # Constant-time comparison

Why Secure: Compares ALL characters regardless of matches. Response time constant, no information leaked.


Idempotency Implementation

Purpose

Problem: Network failures or Stripe retries can cause duplicate webhook delivery.

Solution: WebhookEvent model with unique constraint on event_id prevents duplicate processing.

Implementation

Source: webhooks.py:112-144

with transaction.atomic():
    webhook_event, created = WebhookEvent.objects.get_or_create(
        event_id=event_id,
        defaults={
            'event_type': event_type,
            'processor': 'stripe',
            'status': 'pending',
            'raw_payload': webhook_data
        }
    )

    if not created:
        if webhook_event.status == 'processed':
            logger.info(f"Duplicate webhook detected - already processed: {event_id}")
            return Response(
                {
                    'status': 'duplicate',
                    'message': 'Webhook already processed',
                    'event_id': event_id
                },
                status=status.HTTP_200_OK
            )
        elif webhook_event.status == 'pending':
            logger.info(f"Webhook currently being processed: {event_id}")
            return Response(
                {
                    'status': 'processing',
                    'message': 'Webhook is currently being processed',
                    'event_id': event_id
                },
                status=status.HTTP_200_OK
            )

Status Flow

Status States:

  • pending: Webhook received, processing started
  • processed: Successfully processed
  • failed: Processing failed (error stored in error_message)

State Transitions:

[New Event]

get_or_create(event_id)

┌─────────────┬─────────────┐
│   created   │ not created │
│  (new)      │  (duplicate)│
└─────┬───────┴──────┬──────┘
      ↓              ↓
status='pending'  Check status
      ↓              ↓
  Process      ┌─────┴─────┐
      ↓        │           │
  ┌───┴───┐  'processed' 'pending'
  │       │     ↓           ↓
success failure Return    Return
  ↓       ↓     200       200
'processed' 'failed'

Concurrent Request Handling

Scenario: Two webhooks with same event_id arrive simultaneously.

Request A (arrives first):

  1. get_or_create(event_id)created=True
  2. Status = 'pending'
  3. Start processing...

Request B (arrives 50ms later, while A still processing):

  1. get_or_create(event_id)created=False (A already created)
  2. Check status → 'pending'
  3. Return 200 "currently being processed"
  4. No duplicate processing Request C (arrives after A completes):
  5. get_or_create(event_id)created=False
  6. Check status → 'processed'
  7. Return 200 "already processed"
  8. No duplicate processing

Database Constraint

Model (from models.md):

class WebhookEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)  # Uniqueness constraint

Why Unique: Database-level guarantee prevents duplicate processing even under high concurrency.


Event Handlers

Event Routing

Source: webhooks.py:246-259

def _process_webhook_event(self, webhook_event, webhook_data):
    """
    Process the webhook event based on event type.

    Currently handles:
    - payment_intent.succeeded: Update payment status and complete transaction
    """
    event_type = webhook_data.get('type')

    if event_type == 'payment_intent.succeeded':
        return self._handle_payment_intent_succeeded(webhook_event, webhook_data)
    else:
        logger.info(f"Ignoring webhook event type: {event_type}")
        return {'handled': False, 'reason': 'event_type_not_supported'}

Supported Events:

  • payment_intent.succeeded: Payment validation

Unsupported Events (logged but ignored):

  • payment_intent.created
  • payment_intent.payment_failed
  • charge.succeeded
  • charge.refunded
  • And 100+ other Stripe event types

Extension Pattern: Add new event types by adding elif branches.


payment_intent.succeeded Handler

Source: webhooks.py:261-315

Purpose: Validate payment that was already processed by POS.

Critical Architecture Note (webhooks.py:263-266):

"""
Validate payment that was already processed by Electron POS.

This webhook serves as async validation/reconciliation, NOT payment processing.
Payment was already confirmed by Stripe Terminal before Django received it.
"""

POS Flow (see POS Integration Flow for details):

  1. POS creates PaymentIntent
  2. Customer taps card at Stripe Terminal
  3. Terminal confirms payment with Stripe
  4. POS creates Django payment with status='succeeded' ← Payment already processed
  5. Stripe sends webhook (this handler) ← Validation only

Implementation:

def _handle_payment_intent_succeeded(self, webhook_event, webhook_data):
    from decimal import Decimal

    payment_intent = webhook_data.get('data', {}).get('object', {})
    payment_intent_id = payment_intent.get('id')
    amount_received = Decimal(str(payment_intent.get('amount_received', 0))) / Decimal('100')

    logger.info(f"Validating payment_intent.succeeded: {payment_intent_id}, amount: \${amount_received}")

    # Find payment by processor_transaction_id
    try:
        payment = TransactionPayment.objects.select_related('transaction').get(
            processor_transaction_id=payment_intent_id
        )

        # Validate amount matches
        if payment.amount != amount_received:
            logger.warning(
                f"Amount mismatch for {payment_intent_id}: "
                f"Django=\${payment.amount}, Stripe=\${amount_received}"
            )
        else:
            logger.info(f"Amount validated: \${amount_received}")

        # Link webhook to payment for audit trail
        webhook_event.payment = payment
        webhook_event.save(update_fields=['payment'])
        logger.info(f"Linked WebhookEvent {webhook_event.event_id} to payment {payment.id} for audit trail")

        logger.info(f"Webhook validated payment {payment.id} (already succeeded via Electron)")

        return {
            'success': True,
            'payment_id': str(payment.id),
            'validation': 'confirmed',
            'amount_match': payment.amount == amount_received
        }

    except TransactionPayment.DoesNotExist:
        # Race condition: Webhook arrived before Electron POST
        logger.info(
            f"Webhook for {payment_intent_id} - payment not yet created (Electron POST pending or test webhook)"
        )

        return {
            'success': True,
            'reason': 'payment_pending_creation',
            'webhook_stored': True
        }

Processing Steps:

1. Extract Payment Data (webhooks.py:270-274):

payment_intent = webhook_data.get('data', {}).get('object', {})
payment_intent_id = payment_intent.get('id')
amount_received = Decimal(str(payment_intent.get('amount_received', 0))) / Decimal('100')

Amount Conversion: Stripe uses cents. $125.50 = 12550 cents → divide by 100.

2. Lookup Payment (webhooks.py:277-280):

payment = TransactionPayment.objects.select_related('transaction').get(
    processor_transaction_id=payment_intent_id
)

Key: processor_transaction_id field stores Stripe's PaymentIntent ID for linking.

3. Amount Validation (webhooks.py:282-289):

if payment.amount != amount_received:
    logger.warning(
        f"Amount mismatch for {payment_intent_id}: "
        f"Django=\${payment.amount}, Stripe=\${amount_received}"
    )
else:
    logger.info(f"Amount validated: \${amount_received}")

Why Check: Detects data corruption, network errors, or tampering.

Does NOT Fail Webhook: Amount mismatch is logged but webhook still succeeds. Allows manual review.

4. Audit Trail Linking (webhooks.py:291-294):

webhook_event.payment = payment
webhook_event.save(update_fields=['payment'])
logger.info(f"Linked WebhookEvent {webhook_event.event_id} to payment {payment.id} for audit trail")

Purpose: Link WebhookEvent record to payment for compliance auditing.

5. Return Success (webhooks.py:298-303):

return {
    'success': True,
    'payment_id': str(payment.id),
    'validation': 'confirmed',
    'amount_match': payment.amount == amount_received
}

6. Handle Race Condition (webhooks.py:305-315):

See Race Condition Handling section.


Race Condition Handling

Problem

Timing Issue: Webhook may arrive BEFORE POS creates Django payment.

Sequence:

  1. Customer taps card at terminal (time: 0ms)
  2. Stripe processes payment (time: 100ms)
  3. Stripe sends webhook (time: 150ms)
  4. Webhook reaches Django (time: 200ms)
  5. POS creates payment in Django (time: 300ms) ← Race condition

Result: Webhook handler can't find payment (DoesNotExist).

Solution

Graceful Handling (webhooks.py:305-315):

except TransactionPayment.DoesNotExist:
    # Race condition: Webhook arrived before Electron POST
    logger.info(
        f"Webhook for {payment_intent_id} - payment not yet created (Electron POST pending or test webhook)"
    )

    return {
        'success': True,
        'reason': 'payment_pending_creation',
        'webhook_stored': True
    }

Strategy:

  • Don't Fail: Return success=True (200 OK)
  • Store Event: WebhookEvent record saved with raw_payload
  • Manual Review: Admin can match webhook to payment later
  • Alternative: Could implement async retry (check again in 5 seconds)

Why Return Success:

  • Prevents Stripe from retrying (retries won't help, POS request is independent)
  • Webhook data preserved in database for reconciliation
  • POS request will create payment successfully

Future Enhancement:

# Queue async task to recheck in 5 seconds
from celery import current_app
current_app.send_task(
    'payments.tasks.retry_webhook_processing',
    args=[webhook_event.id],
    countdown=5  # Retry after 5 seconds
)

POS Integration Flow

Complete Payment Flow

Step-by-Step Sequence:

1. POS Creates PaymentIntent

POS Action: User enters amount, clicks "Pay with Card"

POS Code:

// POST /payments/terminal/create-payment/
const response = await fetch('/payments/terminal/create-payment/', {
  method: 'POST',
  body: JSON.stringify({
    amount: 12550,  // $125.50 in cents
    currency: 'usd'
  })
});

const { id, client_secret } = await response.json();
// id = "pi_abc123"
// client_secret = "pi_abc123_secret_xyz"

Django Action (services.py):

# PaymentService._process_stripe_payment()
intent = stripe.PaymentIntent.create(
    amount=12550,
    currency='usd'
)
# Returns: {id: "pi_abc123", client_secret: "pi_abc123_secret_xyz"}

Database: NO payment created yet. Just PaymentIntent in Stripe.

2. POS Collects Payment with Terminal

POS Code:

// Use Stripe Terminal SDK
const terminal = StripeTerminal.create({
  onFetchConnectionToken: async () => {
    const response = await fetch('/payments/terminal/connection-token/', {
      method: 'POST'
    });
    const { secret } = await response.json();
    return secret;
  }
});

// Collect payment
const result = await terminal.collectPaymentMethod(client_secret);
const payment = await terminal.processPayment(result.paymentIntent);
// payment.status = "succeeded"

Physical Action: Customer taps/inserts card at terminal.

Stripe Action: Terminal sends card data to Stripe, Stripe processes payment.

Result: PaymentIntent.status = "succeeded" in Stripe.

3. Stripe Sends Webhook (Async)

Stripe Action: Immediately after payment succeeds, Stripe queues webhook.

Webhook Sent (50-500ms later):

POST https://yourdomain.com/payments/webhook/stripe/
Stripe-Signature: t=1700000000,v1=abc123...

{
  "id": "evt_xyz789",
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_abc123",
      "amount_received": 12550,
      "status": "succeeded"
    }
  }
}

Django Action: Webhook handler processes event (see above).

Race Condition: Webhook may arrive BEFORE step 4.

4. POS Creates Django Payment

POS Code:

// After terminal.processPayment succeeds
const paymentRecord = await fetch('/payments/payments/', {
  method: 'POST',
  body: JSON.stringify({
    transaction: transactionId,
    payment_method: paymentMethodId,
    amount: "125.50",
    status: "succeeded",  // Already succeeded at terminal
    processor_transaction_id: "pi_abc123",  // Link to Stripe PaymentIntent
    processed_at: new Date().toISOString()
  })
});

Django Action: Creates TransactionPayment record.

Database: Payment now exists with processor_transaction_id="pi_abc123".

5. Webhook Validation (If Step 3 Happened After Step 4)

Webhook Handler:

# Find payment by processor_transaction_id
payment = TransactionPayment.objects.get(
    processor_transaction_id="pi_abc123"
)

# Validate amount
if payment.amount == Decimal("125.50"):
    logger.info("Amount validated")

# Link webhook to payment
webhook_event.payment = payment
webhook_event.save()

Result: Payment validated, webhook linked for audit trail.

Timeline Diagrams

Normal Flow (Webhook After Payment):

0ms    POS: Create PaymentIntent

100ms  Terminal: Collect card

200ms  Stripe: Process payment → SUCCEEDED
       ├─→ 250ms: Send webhook
       └─→ 300ms: POS creates Django payment

              350ms: Webhook processes (payment found)

                  Validation complete

Race Condition Flow (Webhook Before Payment):

0ms    POS: Create PaymentIntent

100ms  Terminal: Collect card

200ms  Stripe: Process payment → SUCCEEDED
       ├─→ 250ms: Send webhook
       │          ↓
       │      300ms: Webhook processes (payment NOT found)
       │            ↓
       │          Webhook stored, return success

       └─→ 400ms: POS creates Django payment

              Payment created (webhook already stored)

Testing Webhooks

Stripe CLI Testing

Install Stripe CLI:

brew install stripe/stripe-cli/stripe
stripe login

Forward Webhooks to Local:

stripe listen --forward-to localhost:8000/payments/webhook/stripe/

Output:

> Ready! Your webhook signing secret is whsec_abc123... (^C to quit)

Update .env:

STRIPE_WEBHOOK_SECRET=whsec_abc123...

Trigger Test Event:

stripe trigger payment_intent.succeeded

Expected Logs:

INFO: === STRIPE WEBHOOK ENDPOINT HIT ===
INFO: Verified Stripe webhook: payment_intent.succeeded (evt_...)
INFO: Webhook for pi_... - payment not yet created (test webhook)
INFO: Successfully processed Stripe webhook: payment_intent.succeeded (evt_...)

Manual cURL Testing

Test GET Endpoint:

curl http://localhost:8000/payments/webhook/stripe/

Expected:

{
  "status": "ok",
  "message": "Stripe webhook endpoint is accessible"
}

Test POST with Signature (requires valid signature):

# Get timestamp
timestamp=$(date +%s)

# Construct payload
payload='{"id":"evt_test","type":"payment_intent.succeeded"}'

# Calculate signature
signature=$(echo -n "\${timestamp}.\${payload}" | openssl dgst -sha256 -hmac "whsec_YOUR_SECRET" | sed 's/^.* //')

# Send request
curl -X POST http://localhost:8000/payments/webhook/stripe/ \
  -H "Content-Type: application/json" \
  -H "Stripe-Signature: t=\${timestamp},v1=\${signature}" \
  -d "\${payload}"

Production Testing

Stripe Dashboard → Developers → Webhooks:

  1. Add endpoint: https://yourdomain.com/payments/webhook/stripe/
  2. Select events: payment_intent.succeeded
  3. Get webhook signing secret: whsec_...
  4. Update production environment variable

Send Test Webhook:

  • Click "Send test webhook" in Stripe Dashboard
  • Select payment_intent.succeeded
  • Click "Send test webhook"

Verify:

  • Check Django logs for webhook processing
  • Check WebhookEvent model for stored event
  • Verify signature validation passed

Was this page helpful?