Webhooks

Webhooks are how JKAPay tells your server when a transaction reaches a final state. They're free, real-time, signed, and retried automatically until your server returns a 2xx response.

How delivery works

  1. You call POST /v1/payments/initialize with a callbackUrl. The customer is prompted on their phone.
  2. The customer authorises or declines the payment.
  3. JKAPay normalises the result and signs the payload with HMAC-SHA256.
  4. JKAPay POSTs the signed payload to your callbackUrl.
  5. Your endpoint returns 2xx → delivered. Any other status or a network error → JKAPay retries with exponential backoff up to 8 times.
One callback per final state. JKAPay dedupes upstream retries — if the mobile network sends us the same SUCCESS twice, you receive exactly one webhook.

Request headers JKAPay sends

Headers
Content-Type
string
application/json
User-Agent
string
JKAPay-Webhooks
X-JKAPay-Signature
string
The HMAC-SHA256 digest of the request body. Format: v1=<hex digest>. See Verifying webhook signatures.
X-JKAPay-Timestamp
string
Unix seconds when JKAPay signed the payload. Used for replay-attack protection.
X-JKAPay-Key-Id
string
The pk_… id of the API key whose secret was used to sign this delivery. Useful when you've issued multiple keys.

Payload

HTTP
POST /webhooks/jkapay HTTP/1.1
Host: webhooks.example.com
Content-Type: application/json
User-Agent: JKAPay-Webhooks
X-JKAPay-Signature: v1=58ab8e9c…
X-JKAPay-Timestamp: 1748192400
X-JKAPay-Key-Id: pk_test_xxxxx

{
  "createdAt": "2026-05-25T16:30:18.213Z",
  "data": {
    "reference": "JKA_M3A8_8F2E1B4C",
    "clientReference": "ORD-12345",
    "status": "SUCCESS",
    "amount": "1.00",
    "fee": "0.02",
    "net": "0.98",
    "currency": "GHS",
    "customer": { "msisdn": "233244000000", "network": "MTN" },
    "description": "Order #12345",
    "metadata": { "orderId": "12345" },
    "completedAt": "2026-05-25T16:30:18.213Z"
  }
}

Payload fields

Body
createdAt
string (ISO 8601)
When JKAPay generated this callback. Distinct from data.completedAt (which is when the transaction settled).
data.reference
string
JKAPay's reference for the transaction. The primary key on your side should be this.
data.clientReference
string | null
The merchantRef you sent at initialize. Use to reconcile against your own system.
data.status
string
Either SUCCESS or FAILED. The transaction's final state. Branch on this — there is no separate event field.
data.amount
string
Transaction amount as a GHS string.
data.fee
string
Platform fee deducted on success. "0.00" on failed transactions.
data.net
string
Amount you receive after the platform fee.
data.currency
string
Always GHS today.
data.customer
object
The customer's msisdn (normalised) and network. The customer's resolved name is intentionally not included on the wire — it's still visible in your dashboard.
data.description
string
The description you supplied at initialize.
data.metadata
object | null
The metadata you supplied at initialize, echoed back unchanged.
data.completedAt
string (ISO 8601)
When the transaction reached its final state.
data.sandbox
boolean (optional)
Present and true only on test-mode deliveries. Use to gate any fulfilment / settlement step in shared environments. See Sandbox / test mode.
Branching on outcome. Inspect data.status: SUCCESS means money has moved; FAILED means it hasn't and won't (the customer declined, timed out, had insufficient funds, or the network rejected the prompt).
Test-key callbacks carry a sandbox flag. When the originating request used a sk_test_… key, the payload includes data.sandbox: true and data.customer.network: "SANDBOX". Any fulfilment step that ships goods, settles funds, or notifies a real customer should refuse to act when data.sandbox === true. See Sandbox / test mode.

Best practices

  • Always verify the signature. See Verifying webhook signatures. Treat unsigned or invalid deliveries as if they never happened.
  • Respond 2xx fast. Acknowledge first, do the heavy work async (queue it). JKAPay times out delivery after 15 seconds.
  • Be idempotent. Even with our dedupe, network conditions can cause repeats. Key your processing on data.reference.
  • Handle out-of-order events. Compare X-JKAPay-Timestamp against your stored value — older timestamps mean a stale delivery you've already processed.
  • Don't trust the source IP. JKAPay doesn't publish a stable egress IP. Use the signature, not network-layer filtering.

Retry schedule

If your endpoint returns non-2xx or times out, JKAPay retries on this schedule:

  • Attempt 1: immediate
  • Attempt 2: +5 seconds
  • Attempt 3: +10 seconds
  • Attempt 4: +20 seconds
  • Attempt 5: +40 seconds
  • Attempt 6: +80 seconds
  • Attempt 7: +160 seconds
  • Attempt 8: +320 seconds

After 8 failed attempts the delivery is marked FAILED. Every attempt is visible in the Webhooks dashboard, where you can replay deliveries by hand.