Skip to content

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.