Build
Webhooks
AFRIPEI posts events to the HTTPS URL you register. Your handler should verify the signature, store the event id, and return 2xx quickly. Do the slow work after you respond.
Add an endpoint
curl https://api.afripei.co.tz/v1/webhook_endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/afripei",
"description": "Orders",
"event_types": ["payment.success", "payment.failed", "payout.success", "payout.failed"]
}'The response includes the signing secret once. Store it. Adding an endpoint also queues matching events from the last 30 days, up to 100, so a new endpoint can catch up.
Use ["*"] only when you want every event. Prefer the four events above for an order system.
The event body
The body is JSON. data is the payment or payout object at the moment the event was stored. Read type before you read data.
{
"id": "evt_...",
"object": "event",
"type": "payment.success",
"api_version": "2026-09-29",
"created_at": "2026-10-20T12:00:00.000Z",
"livemode": true,
"data": { "id": "pay_...", "object": "payment", "status": "success", "amount": "500.00", "currency": "TZS" }
}Verify the signature
The signature is HMAC-SHA256 of timestamp + "." + raw body, using the endpoint secret. Compare it to each v1= value in X-AFRIPEI-Signature. After a secret rotation the header can carry the new secret and the previous one, separated by a comma. Reject a timestamp more than five minutes from now. Use the raw body bytes, not a re-serialized object.
X-AFRIPEI-Signature: v1=<hex>
X-AFRIPEI-Timestamp: 1759151051
X-AFRIPEI-Event-ID: evt_...import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, timestamp, rawBody, header) {
const now = Math.floor(Date.now() / 1000);
const stamp = Number(timestamp);
if (!Number.isFinite(stamp) || Math.abs(now - stamp) > 300) return false;
const expected = createHmac("sha256", secret).update(stamp + "." + rawBody).digest("hex");
return header.split(",").some((part) => {
const value = part.trim().replace(/^v1=/, "");
const left = Buffer.from(value);
const right = Buffer.from(expected);
return left.length === right.length && timingSafeEqual(left, right);
});
}Treat X-AFRIPEI-Event-ID as your idempotency key. Retries send the same event id. A second delivery of an event you already applied must not move money or fulfill an order again.
Payment events
| Event | When it fires |
|---|---|
| payment.created | The payment row exists. |
| payment.pending | The phone prompt was accepted. |
| payment.awaiting_customer | A control number is waiting. |
| payment.success | The customer paid. Fulfill here. |
| payment.failed | The prompt or the wallet failed. |
| payment.cancelled | The payment was cancelled. |
| payment.expired | The prompt or number timed out. |
Payout events
payout.created, payout.initiated, payout.pending, payout.processing, payout.success, payout.failed, and payout.cancelled.
Operate the endpoint
GET /v1/webhook_endpoints lists endpoints. POST /v1/webhook_endpoints/:id/rotate_secret issues a new secret. POST /v1/webhook_endpoints/:id/test sends a signed test event. POST /v1/events/:id/replay sends one stored event again. GET /v1/events lists what was recorded.
Deliveries that fail are retried on this schedule, counted from the failed attempt: immediately, then 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, and 24 hours. After that the delivery is dead. Return 2xx only after you have stored the event id. A timeout is a failure and will be retried.