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
- Security Architecture
- Webhook Receiver
- Security Verification
- Idempotency Implementation
- Event Handlers
- Race Condition Handling
- POS Integration Flow
- Testing Webhooks
- Related Documentation
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:
- Signature Verification: Every webhook validated with HMAC-SHA256
- Replay Attack Prevention: Timestamp validation (5-minute tolerance)
- Idempotency: Duplicate event detection prevents double-processing
- CSRF Exempt: Webhooks bypass CSRF (signature replaces CSRF protection)
- 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 creationv1: 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:
- Attacker captures legitimate webhook at time T
- Attacker waits 10 minutes
- Attacker replays webhook at time T+10min
- Timestamp validation fails (10 min > 5 min tolerance)
- 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 startedprocessed: Successfully processedfailed: 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):
get_or_create(event_id)→created=True- Status = 'pending'
- Start processing...
Request B (arrives 50ms later, while A still processing):
get_or_create(event_id)→created=False(A already created)- Check status → 'pending'
- Return 200 "currently being processed"
- No duplicate processing Request C (arrives after A completes):
get_or_create(event_id)→created=False- Check status → 'processed'
- Return 200 "already processed"
- 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.createdpayment_intent.payment_failedcharge.succeededcharge.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):
- POS creates PaymentIntent
- Customer taps card at Stripe Terminal
- Terminal confirms payment with Stripe
- POS creates Django payment with status='succeeded' ← Payment already processed
- 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:
- Customer taps card at terminal (time: 0ms)
- Stripe processes payment (time: 100ms)
- Stripe sends webhook (time: 150ms)
- Webhook reaches Django (time: 200ms)
- 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:
- Add endpoint:
https://yourdomain.com/payments/webhook/stripe/ - Select events:
payment_intent.succeeded - Get webhook signing secret:
whsec_... - 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
Related Documentation
- Models - WebhookEvent, TransactionPayment models
- Serializers - Payment serialization
- Views - StripePaymentIntentView, StripeConnectionTokenView
- Services - Payment processing logic
- Signals - Payment event handlers
- Transactions Domain - Transaction processing