Webhooks
Webhooks
QRPay sends webhook notifications to your endpoint for real-time status updates on payments and payouts.
Endpoint Requirements
Your webhook endpoint must:
- Accept HTTP POST requests
- Use HTTPS (required in production)
- Return 2xx status code within 10 seconds
- Verify webhook signature before processing
- Handle idempotent processing
- Be publicly accessible
Webhook Headers
All webhooks include these headers:
| Header | Description |
|---|---|
X-QRPay-Signature | HMAC-SHA256 signature (hex-encoded) |
X-QRPay-Timestamp | Unix timestamp in seconds |
X-QRPay-Event-Id | Unique event UUID |
X-QRPay-Key-Id | KID of the webhook secret used |
Payment Events
Event Types
| Event Type | Description | Status |
|---|---|---|
payment.captured | Payment successful | captured |
payment.failed | Payment failed | failed |
payment.cancelled | User cancelled | cancelled |
Payload Example
{ "event_type": "payment.captured", "event_id": "01932e5d-7f8a-7890-b123-456789abcdef", "timestamp": 1704636000, "payment_id": "01932e5d-7000-7890-b123-456789abcdef", "merchant_order_id": "ORDER-2025-001", "status": "captured", "amount": 10000, "currency": "ZAR", "fulfillment_data": { "type": "voucher", "vouchers": [{ "id": "01932e5d-8000-7890-b123-456789abcdef", "code": "ABCD-EFGH-IJKL-MNOP", "status": "redeemed" }] }}Payout Events
Event Types
| Event Type | Description | Status |
|---|---|---|
payout.created | Payout created | pending |
payout.approved | Approved by operator | approved |
payout.processing | Transfer initiated | processing |
payout.completed | Transfer successful | completed |
payout.failed | Transfer failed | failed |
payout.rejected | Rejected by operator | rejected |
Payload Example
{ "event_type": "payout.completed", "event_id": "01932e5d-7f8a-7890-b123-456789abcdef", "timestamp": 1704636330, "payout_id": "01932e5d-7000-7890-b123-456789abcdef", "merchant_reference": "PAYOUT-2025-001", "status": "completed", "amount_cents": 50000, "currency": "ZAR", "payout_method": "eft", "external_transfer_id": "TXN-ABC123XYZ789"}Signature Verification
QRPay signs all webhooks using HMAC-SHA256:
canonical_string = "{timestamp}.{event_id}.{raw_body}"signature = HMAC-SHA256(webhook_secret, canonical_string)Verification Steps
- Extract headers
- Validate timestamp (within ±5 minutes)
- Compute expected signature
- Compare signatures (constant-time)
- Process idempotently
Python Example
import hmacimport hashlibimport timeimport jsonfrom flask import Flask, request, jsonifyimport os
app = Flask(__name__)WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
@app.route('/webhooks/qrpay', methods=['POST'])def handle_webhook(): # Extract headers signature = request.headers.get('X-QRPay-Signature') timestamp_str = request.headers.get('X-QRPay-Timestamp') event_id = request.headers.get('X-QRPay-Event-Id')
if not all([signature, timestamp_str, event_id]): return jsonify({'error': 'Missing headers'}), 400
# Validate timestamp (within 5 minutes) timestamp = int(timestamp_str) if abs(int(time.time()) - timestamp) > 300: return jsonify({'error': 'Timestamp too old'}), 400
# Compute expected signature raw_body = request.get_data(as_text=True) canonical_string = f"{timestamp}.{event_id}.{raw_body}" expected_signature = hmac.new( WEBHOOK_SECRET.encode('utf-8'), canonical_string.encode('utf-8'), hashlib.sha256 ).hexdigest()
# Verify signature (constant-time comparison) if not hmac.compare_digest(signature, expected_signature): return jsonify({'error': 'Invalid signature'}), 401
# Parse and process payload = json.loads(raw_body)
# Check idempotency if event_already_processed(payload['event_id']): return jsonify({'message': 'Already processed'}), 200
# Handle event event_type = payload['event_type'] if event_type == 'payment.captured': handle_payment_captured(payload) elif event_type == 'payout.completed': handle_payout_completed(payload) # ... handle other events
mark_event_processed(payload['event_id']) return jsonify({'received': True}), 200Retry Behavior
QRPay retries webhook delivery if:
- HTTP 5xx (server error)
- HTTP 429 (rate limited)
- HTTP 408 (timeout)
- Network error
Retry Schedule:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | ~1 second |
| 3 | ~5 seconds |
| 4 | ~10 seconds |
| 5 | ~30 seconds |
Total: 5 attempts (1 initial + 4 retries)
Idempotency
Implement idempotent processing to handle duplicate deliveries:
- Store
event_idwhen processing begins - Check if exists before processing
- Return 200 OK for duplicates without re-processing
def event_already_processed(event_id: str) -> bool: # Check your database return db.events.exists(event_id)
def mark_event_processed(event_id: str): # Store in your database db.events.insert(event_id, processed_at=datetime.now())Best Practices
- Return quickly - Respond within 10 seconds, process async if needed
- Verify signatures - Always validate before processing
- Handle duplicates - Use event_id for idempotency
- Log everything - Store raw payloads for debugging
- Monitor failures - Alert on webhook delivery issues