Skip to content

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:

HeaderDescription
X-QRPay-SignatureHMAC-SHA256 signature (hex-encoded)
X-QRPay-TimestampUnix timestamp in seconds
X-QRPay-Event-IdUnique event UUID
X-QRPay-Key-IdKID of the webhook secret used

Payment Events

Event Types

Event TypeDescriptionStatus
payment.capturedPayment successfulcaptured
payment.failedPayment failedfailed
payment.cancelledUser cancelledcancelled

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 TypeDescriptionStatus
payout.createdPayout createdpending
payout.approvedApproved by operatorapproved
payout.processingTransfer initiatedprocessing
payout.completedTransfer successfulcompleted
payout.failedTransfer failedfailed
payout.rejectedRejected by operatorrejected

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

  1. Extract headers
  2. Validate timestamp (within ±5 minutes)
  3. Compute expected signature
  4. Compare signatures (constant-time)
  5. Process idempotently

Python Example

import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
import 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}), 200

Retry Behavior

QRPay retries webhook delivery if:

  • HTTP 5xx (server error)
  • HTTP 429 (rate limited)
  • HTTP 408 (timeout)
  • Network error

Retry Schedule:

AttemptDelay
1Immediate
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:

  1. Store event_id when processing begins
  2. Check if exists before processing
  3. 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

  1. Return quickly - Respond within 10 seconds, process async if needed
  2. Verify signatures - Always validate before processing
  3. Handle duplicates - Use event_id for idempotency
  4. Log everything - Store raw payloads for debugging
  5. Monitor failures - Alert on webhook delivery issues