Webhooks
Every event type, the payload, verifying signatures in five languages, retries, duplicates and ordering.
Webhooks tell your server when something happens, such as a payout succeeding, so you don't have to poll. We POST a signed JSON event to an https URL you choose, retry if you don't answer, and keep every event readable through the API.
Set up an endpoint
Write a handler
An https route on your server that reads the raw body, checks the signature, answers
2xxquickly and processes the event in the background.Add it
In the Console's Webhooks (choose Test or Live first), or with POST /api/v1/webhook-endpoints. Pick the events, or
*for all. Copy the signing secret (whsec_…) into your secrets.Send a test event
Send test event in the Console delivers a
webhook.testevent to that endpoint only. Check your handler accepts it, then trigger real ones with sandbox payouts.
URLs must be https and on the public internet; private, local and link-local addresses are refused. We don't follow redirects (a 3xx counts as a failure).
Event types
| Type | Sent when | data.object |
|---|---|---|
payout.created | A payout is accepted (status pending). | payout |
payout.processing | It's on its way. | payout |
payout.succeeded | The recipient has the money. | payout |
payout.failed | It didn't go through; money returned. | payout |
payout.reversed | A succeeded payout came back. | payout |
payout.in_review | Held for a manual check. | payout |
balance.low | Your live balance dropped below your alert level (checked hourly). | { object: balance, total_usd, threshold_usd } |
api_key.expiring | A key expires within 30, 7 or 1 days. | { object: api_key, id, name, prefix, expires_at, days_left } |
webhook.test | You pressed Send test event (that endpoint only). | { object: webhook_endpoint, id, message } |
topup.pending / topup.succeeded / topup.failed | Reserved; not sent yet. | - |
payment_link.paid | Reserved; not sent yet. | - |
What we send
POST /webhooks/autopayke HTTP/1.1
Content-Type: application/json
User-Agent: AutoPayKe-Webhooks/1.0
AutoPayKe-Signature: t=1793782206,v1=5f8a0c1b9e2d7f3a6b4c8e1d0f9a2b7c3e6d5f4a1b8c9e0d7f2a3b6c5e4d1f0a
AutoPayKe-Event-Id: evt_3Jk8Qw2Zp7Lm4Nx6Rt1Vb9Yc
AutoPayKe-Event-Type: payout.succeeded
{
"id": "evt_3Jk8Qw2Zp7Lm4Nx6Rt1Vb9Yc",
"object": "event",
"type": "payout.succeeded",
"created": "2026-11-03T09:10:06.000Z",
"livemode": true,
"data": {
"object": {
"id": "po_8Fq2mZx7Lk3Rt9Vb",
"object": "payout",
"livemode": true,
"status": "succeeded",
"amount": 150000,
"currency": "KES",
"source_token": "USDC",
"debited_usdc": "11.948310",
"fee_usdc": "0.302310",
"rate": "128.80",
"destination": { "type": "mobile_money", "network": "safaricom", "phone": "+254 712 ••• 678" },
"recipient_name": "JANE WANJIKU DOE",
"reference": "order_1042",
"description": null,
"metadata": { "rider_id": "r_5521" },
"failure_code": null,
"failure_message": null,
"created": "2026-11-03T09:10:00.000Z",
"updated": "2026-11-03T09:10:06.000Z"
}
}
}data.object is the object exactly as GET would return it at that moment, so the payload and the API never disagree. Phone and account numbers are masked.
Verify the signature
Every delivery has AutoPayKe-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256, keyed with your endpoint's signing secret, of the timestamp, a full stop, and the raw request body: `${t}.${rawBody}`. Recompute it, compare in constant time, and reject anything older than 5 minutes. Use the raw bytes you received: parsing and re-serialising the JSON changes them.
# PHP 8
<?php
$secret = getenv('AUTOPAYKE_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_AUTOPAYKE_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$t = (int)($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!$t || abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);
http_response_code(200);
// queue $event for processing// Go
func verify(raw []byte, header, secret string) bool {
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
t, _ = strconv.ParseInt(kv[1], 10, 64)
case "v1":
v1 = kv[1]
}
}
if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d.", t)))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}// Java 17
static boolean verify(String raw, String header, String secret) throws Exception {
long t = 0; String v1 = null;
for (String part : header.split(",")) {
String[] kv = part.trim().split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = Long.parseLong(kv[1]);
if (kv[0].equals("v1")) v1 = kv[1];
}
if (t == 0 || v1 == null || Math.abs(Instant.now().getEpochSecond() - t) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal((t + "." + raw).getBytes(StandardCharsets.UTF_8));
return MessageDigest.isEqual(HexFormat.of().formatHex(sig).getBytes(), v1.getBytes());
}For extra certainty, fetch the event with GET /api/v1/events/:id using the id in the payload, and act on what that returns.
Answering, retries and failures
- Answer with any
2xxwithin 10 seconds. Anything else, a timeout or a connection error counts as a failure. - We retry failures 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours after the previous try, then daily, for up to 3 days.
- If deliveries to an endpoint keep failing for 3 days, we disable it and email your technical contact. Fix it and turn it back on in the Console; read what you missed from
GET /api/v1/events. - You can retry any delivery from the Console, and see each attempt's status, response code, body and timing.
Duplicates and order
Deliveries are at-least-once: you may receive the same event twice (for example if your 200 got lost). Store processed event ids and skip repeats. Events can also arrive out of order, especially after retries. Use the object's status and updated rather than arrival order: never move a payout from a final status back to an earlier one.
const FINAL = new Set(["succeeded", "failed", "reversed"]);
async function handle(event) {
if (await db.processedEvents.has(event.id)) return; // duplicate
if (event.type.startsWith("payout.")) {
const p = event.data.object;
const mine = await db.payouts.findByAutopaykeId(p.id);
// Ignore stale updates: a final status only ever moves to reversed.
if (mine && FINAL.has(mine.status) && !(mine.status === "succeeded" && p.status === "reversed")) return;
await db.payouts.update(p.id, { status: p.status, failureCode: p.failure_code, updatedAt: p.updated });
}
await db.processedEvents.add(event.id);
}Rolling the signing secret
Rolling replaces the secret at once. To roll without dropping events: deploy code that accepts a signature from either the old or the new secret, roll, update the new secret in your config, then remove the old one.
Missed events
Every event stays readable at GET /api/v1/events (filter by type or object_id). After an outage, page from the newest until you reach an id you've processed, and handle the rest.