Webhook Integration Guide
This guide walks through setting up your webhook endpoint to receive payment events.
Setup
sequenceDiagram
participant You as Your Server
participant API as Payments API
You->>API: PUT /v1/merchant/webhook<br/>{ webhook_url: "https://you.com/hook" }
API-->>You: { webhook_secret: "whsec_..." }
Note over You: Save the secret!
Note over API,You: Later, when a payment completes...
API->>You: POST https://you.com/hook<br/>X-Event-Type: payment.completed<br/>X-Payments-Signature: sha256=...
You-->>API: 200 OK
Step 1: Register your URL
curl -X PUT https://api.payments.com/v1/merchant/webhook \
-H "X-API-Key: sk_live_..." \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://yourapp.com/webhooks/payments"}'
Step 2: Save the secret
The response contains webhook_secret — store it securely. It's shown once.
Step 3: Implement your handler
from fastapi import FastAPI, Request, Response
import hmac, hashlib, time, json
app = FastAPI()
WEBHOOK_SECRET = "whsec_your_secret_here"
@app.post("/webhooks/payments")
async def handle_webhook(request: Request):
body = await request.body()
signature = request.headers.get("X-Payments-Signature", "")
timestamp = request.headers.get("X-Payments-Timestamp", "")
event_type = request.headers.get("X-Event-Type", "")
# 1. Verify signature
if not verify(body, WEBHOOK_SECRET, signature, timestamp):
return Response(status_code=401)
# 2. Parse payload
payload = json.loads(body)
# 3. Handle by event type
match event_type:
case "payment.completed":
fulfill_order(payload["data"]["payment_id"])
case "payment.failed":
notify_failure(payload["data"])
case "payment.reversed" | "payment.refunded":
process_refund(payload["data"])
return Response(status_code=200)
def verify(payload, secret, signature, timestamp, tolerance=300):
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = f"{timestamp}.".encode() + payload
expected = hmac.HMAC(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature.removeprefix("sha256="))
Headers
Every webhook delivery includes:
| Header | Example | Description |
|---|---|---|
X-Event-Type |
payment.completed |
Event type for routing |
X-Payments-Signature |
sha256=abc123... |
HMAC-SHA256 signature |
X-Payments-Timestamp |
1717500000 |
Unix timestamp |
X-Webhook-Id |
01J... |
Unique delivery ID (use for idempotency) |
Event Types
| Event | When | Providers |
|---|---|---|
payment.completed |
Payment succeeded | All |
payment.failed |
Payment declined/errored | All |
payment.reversed |
M-Pesa reversal completed | M-Pesa |
payment.refunded |
Refund completed | Stripe, PayPal |
Payload Format
{
"id": "evt_01J...",
"type": "payment.completed",
"data": {
"payment_id": "pay_01J...",
"amount": "5000",
"currency": "KES",
"provider": "mpesa",
"status": "completed",
"previous_status": "pending"
}
}
The previous_status field shows the state transition.
Retry Behavior
If your endpoint returns a non-2xx status or times out (30s), we retry:
| Attempt | Delay |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After 5 failures → marked dead for manual review.
Check delivery status via GET /v1/webhooks/logs.
Best Practices
Return 200 quickly
Process the webhook asynchronously. Return 200 immediately, then handle the business logic in a background task.
Use X-Webhook-Id for idempotency
We may retry deliveries. Use the X-Webhook-Id header to deduplicate on your side.
Verify signatures always
Never trust the payload without verifying the HMAC signature. Reject requests older than 5 minutes.