Start here
Events and webhooks
Webhooks tell your server about changes as they happen, such as a PIN being issued or redeemed. AfroPin sends a signed POST request with a JSON body to the URL you register.
Set up a webhook
-
Create an endpoint on your server
It must accept POST requests over HTTPS with a valid certificate. Turn off CSRF protection for this route; frameworks like Laravel, Rails and Django reject POSTs without a CSRF token.
-
Register it in the dashboard
Under Webhooks, add a label, the URL and a signing secret of 16 to 100 characters. The webhook belongs to the mode your dashboard is in, test or live.
-
Attach it to requests
Copy the webhook ID and pass it as
webhook_id. A webhook passed when you issue a PIN is stored on the PIN, and every later event for that PIN goes to it. Change it with Change the webhook on a PIN.
Request format
Every webhook is a POST with Content-Type: application/json and the same two top-level fields:
typepin.spent.datawebhook_id and the idempotency_key of the API request that caused them (null for events from background checks, such as expiry).webhook-secret headerVerify the signature
Your webhook URL is public, so check every request before trusting it. Compute the HMAC-SHA256 of the raw body with your signing secret and compare it with the webhook-secret header using a constant-time comparison. If the header is missing or does not match, reject the request.
PHP
// Laravel route, e.g. in routes/api.php
Route::post('webhook', function (Illuminate\Http\Request $request) {
$secret = env('WEBHOOK_SECRET');
// Sign the raw body exactly as received; re-encoding the JSON can change it
$expected = hash_hmac('sha256', $request->getContent(), $secret);
$signature = (string) $request->header('webhook-secret');
if (!hash_equals($expected, $signature)) {
abort(401);
}
$type = $request->input('type');
$data = $request->input('data');
// Acknowledge first; do slow work in a queued job
ProcessWebhook::dispatch($type, $data);
return response()->noContent(200);
});
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
// express.raw keeps the body as bytes so it can be hashed unchanged
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const signature = req.get('webhook-secret') || '';
if (signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.sendStatus(401);
}
const { type, data } = JSON.parse(req.body);
res.sendStatus(200); // acknowledge first
handleEvent(type, data); // then do the work
});
Python
import hashlib, hmac, json, os
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def webhook(request):
expected = hmac.new(os.environ["WEBHOOK_SECRET"].encode(), request.body, hashlib.sha256).hexdigest()
signature = request.headers.get("webhook-secret", "")
if not hmac.compare_digest(expected, signature):
return HttpResponse(status=401)
payload = json.loads(request.body)
handle_event.delay(payload["type"], payload["data"]) # queue the work
return HttpResponse(status=200)
Responding and retries
- Return any 2xx status within 3 seconds to acknowledge the webhook. The response body is ignored.
- Any other status, including 3xx redirects, a timeout or a connection error counts as a failure.
- A failed webhook is tried up to 3 times in total: again after about 10 seconds, then after about 100 seconds.
- Each delivery is logged in the dashboard with the response your server returned. After an hour you can resend a webhook from there by hand.
Events
Select an event to see when it is sent and an example payload.
You issued a PIN with a webhook_id.
keep_code and daily_limit are the customer's choices at the sale (see Sell a PIN). handle_delivery says whether we deliver to the customer (see Delivery to customers below). pin_code is the new PIN's full code, the same one the issue response returns, so a webhook alone can deliver it to your customer. Treat it like a password; your webhook logs show it as ****-****-****-****. source is api for a PIN issued through the API, terminal for one sold or activated at one of your tills, or dashboard for one sent with Bulk send in your dashboard (terminal and dashboard PINs are always delivered by us). terminal is that till (its id and name, and the id and name of its store), or null for a PIN issued without one. physical_card is null for a PIN issued directly; see the second example for a PIN issued by activating one of your printed cards.
PIN issued directly
{
"type": "pin.sold",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"store_id": null,
"idempotency_key": "00cb45cf-fe6e-4feb-9bce-6091f64de13f",
"transaction_ref": "e554b024-ec88-40f1-aec9-9212e9713541",
"pin_code": "7667-2190-4415-7157",
"pin_mask": "7667-****-****-7157",
"amount": 50,
"balance": 50,
"currency": "NGN",
"mode": "test",
"customer": {
"first_name": "Ada",
"last_name": "Okafor",
"email": "[email protected]",
"phone": "+2348031234567"
},
"status": "active",
"external_reference": "order_10293",
"expires_at": "2027-10-05T09:14:22.000000Z",
"sold_at": "2026-10-05T09:14:22.000000Z",
"source": "api",
"terminal": null,
"physical_card": null,
"keep_code": false,
"daily_limit": null,
"handle_delivery": false
}
}
PIN issued by activating a printed card
Sent when one of your tills or the Activate a physical card endpoint activates a printed card. The PIN is a new one, issued for the amount loaded, and is delivered like any other. physical_card identifies the card: card_mask is the printed card number, masked (the full number is never sent and no longer works); serial is the card's number within its order; order_reference is the card order on your Card orders page. Cards activate in live mode only. source is terminal for a till activation and api for one through the endpoint. external_reference is the value sent to the endpoint, or a TRM- reference for till activations; idempotency_key is null for till activations; store_id is the till's store.
{
"type": "pin.sold",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"store_id": "9b1f3c2e-5d7a-4e8b-a1c3-2f6d8e0b4a71",
"idempotency_key": null,
"transaction_ref": "8d2c6a14-9e3f-4b70-a5d1-6c0e8f2b9a47",
"pin_code": "3184-6602-9375-2041",
"pin_mask": "3184-****-****-2041",
"amount": 15000,
"balance": 15000,
"currency": "NGN",
"mode": "live",
"customer": {
"first_name": "Ada",
"last_name": "Okafor",
"email": "[email protected]",
"phone": "+2348031234567"
},
"status": "active",
"external_reference": "TRM-K7Q2M9XW4BN1P8RT5CZA",
"expires_at": "2027-10-05T09:14:22.000000Z",
"sold_at": "2026-10-05T09:14:22.000000Z",
"source": "terminal",
"terminal": {
"id": "850b6f44-7729-4c73-8cf8-0b62320027bc",
"name": "Till 2",
"store_id": "9b1f3c2e-5d7a-4e8b-a1c3-2f6d8e0b4a71",
"store_name": "Ikeja store"
},
"physical_card": {
"card_mask": "9412 **** **** **** 615",
"serial": 42,
"order_reference": "PVO-7M9RXLNW"
},
"keep_code": false,
"daily_limit": null,
"handle_delivery": true
}
}
You topped up a PIN that has a webhook.
When the product issues a new PIN code on top-up, pin_code is the new code and new_pin_mask its mask; both are null otherwise. terminal is the till that topped the PIN up, or null for a top-up through the API.
{
"type": "pin.topped_up",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"store_id": null,
"idempotency_key": "9a4b2c1d-3e5f-4a6b-8c7d-9e0f1a2b3c4d",
"transaction_ref": "0460c5b8-37ab-4227-b7a4-30cd09cb5e7e",
"pin_code": "5224-7730-1186-5981",
"new_pin_mask": "5224-****-****-5981",
"amount": 25,
"balance": 75,
"currency": "NGN",
"mode": "test",
"customer": {
"first_name": "Ada",
"last_name": "Okafor",
"email": "[email protected]",
"phone": "+2348031234567"
},
"status": "active",
"external_reference": "topup_8812",
"terminal": {
"id": "850b6f44-7729-4c73-8cf8-0b62320027bc",
"name": "Till 2",
"store_id": "9b1f3c2e-5d7a-4e8b-a1c3-2f6d8e0b4a71",
"store_name": "Ikeja store"
}
}
}
A brand redeemed one of your PINs that has a webhook.
When the product issues a new PIN code after each use, pin_code is the PIN's new code and new_pin_mask its mask: give the new code to your customer, because the old one no longer works. Both are null otherwise. We never store PIN codes, so this webhook is the only place you will ever see the new code: save it in place of the old one before you respond with a 2xx, treat it like a password and don't log it. Your webhook logs show it as ****-****-****-****. If the delivery fails, the code is kept encrypted for up to 72 hours so a retry or resend can carry it; once delivered or after 72 hours it is deleted, and a later resend sends pin_code as null. Attach a webhook to every PIN of such a product, or a code changed at a brand is lost to you. external_reference and store_id are the brand's values, and terminal is the brand's till that redeemed the PIN (null for a redemption through the brand's API without one); amount is what left the PIN (including the redemption fee when the brand passed it to the customer) and balance is what remains.
{
"type": "pin.spent",
"data": {
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"transaction_ref": "3c6bb90c-4dc9-4e01-bdf2-75e91199847d",
"store_id": "5c0a7e3b-8d21-4f96-b4e7-1a9d6c2f8e05",
"pin_code": "7676-5619-2256-6608",
"new_pin_mask": "7676-****-****-6608",
"amount": 10,
"balance": 65,
"currency": "NGN",
"mode": "live",
"external_reference": "order_4471",
"processed_at": "2026-10-06T12:21:56.000000Z",
"terminal": {
"id": "2d7e9a14-6b3c-4f81-9e25-c04a8b71d3f6",
"name": "Till 1",
"store_id": "5c0a7e3b-8d21-4f96-b4e7-1a9d6c2f8e05",
"store_name": "Lekki store"
}
}
}
A PIN got a new code because its customer lost theirs (from the API, your dashboard or a terminal).
pin_code is the PIN's new code; the old one no longer works. source is api, dashboard or terminal: dashboard and terminal reissues have already emailed the customer.
{
"type": "pin.code_changed",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"idempotency_key": "6c1e2b4a-90d3-4f1e-8a2b-7d5c3e9f0a12",
"source": "api",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"pin_code": "2923-5810-4476-1441",
"pin_mask": "2923-****-****-1441",
"previous_pin_mask": "7676-****-****-6608",
"balance": 65,
"currency": "NGN",
"status": "active",
"mode": "live",
"customer": {
"first_name": "Ada",
"last_name": "Okafor",
"email": "[email protected]",
"phone": "+2348031234567"
},
"expires_at": "2027-10-05T09:14:22.000000Z",
"reissued_at": "2026-10-07T10:02:41.000000Z"
}
}
A PIN passed its expiry date. Checked every 10 minutes, once the expiry date is over.
Expired PINs cannot be redeemed, topped up or re-activated.
{
"type": "pin.expired",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"idempotency_key": null
}
}
Money moved between a PIN and one of its family cards. Family cards are extra cards a customer makes from their own card for family members.
You get this event twice, once for each PIN; pin_id says which one this is. direction is out on the PIN the money left, and in on the PIN it went to, and balance is that PIN's new balance. main_pin_id and family_pin_id are always both filled in, so you can tell the two cards apart. This is not a sale: no wallet is charged and no commission is paid.
{
"type": "pin.moved",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"pin_mask": "7667-****-****-7157",
"currency": "NGN",
"mode": "live",
"direction": "out",
"amount": 2000,
"balance": 8000,
"main_pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"family_pin_id": "9d2f6a31-4c7e-4b18-8a50-3e6f1c0b7d42",
"transaction_ref": "1f0c7b2e-5a94-4d63-b8e1-2c7a9f40d815",
"moved_at": "2026-11-06T10:14:22.000000Z"
}
}
Someone froze a PIN, so it can't be spent.
frozen_by tells you who froze it. seller means you did, with Freeze a PIN or the Freeze button in your dashboard; only you can unfreeze it. cardholder means the customer did, in their card account; only they can unfreeze it. While a PIN is frozen, redeeming it fails with pin_blocked (frozen by you) or pin_frozen (frozen by the customer).
{
"type": "pin.frozen",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"pin_mask": "7667-****-****-7157",
"currency": "NGN",
"mode": "live",
"frozen_by": "cardholder",
"at": "2026-11-06T10:14:22.000000Z"
}
}
A frozen PIN was unfrozen and can be spent again.
frozen_by tells you who unfroze it: seller (you) or cardholder (the customer).
{
"type": "pin.unfrozen",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"pin_id": "4cb813c9-dab7-47ea-a0be-5370ff865cc8",
"pin_mask": "7667-****-****-7157",
"currency": "NGN",
"mode": "live",
"frozen_by": "seller",
"at": "2026-11-06T10:20:05.000000Z"
}
}
Delivery to customers
Webhooks tell your systems what happened; they don't decide who tells the customer. That is a choice on each PIN, handle_delivery, which you send when you sell the PIN or activate a printed card and can change later with Choose who delivers a PIN. With it on, we send the customer their code when the PIN is sold, and the new balance after every top-up and spend, with the new code whenever it changes, including after Give a PIN a new code. With it off, you deliver them yourself from the API responses and your webhook's events.
whatsapp is on (live PINs only). Delivery works the same with or without a webhook. PINs sold at your terminals or by Bulk send are always delivered by us, as nobody on your side sees their codes.Best practices
Handle duplicates
Retries and manual resends mean the same event can arrive more than once. Make your handler idempotent: for example, record the type together with transaction_ref and skip ones you have already processed.
Respond quickly
You have 3 seconds. Return 200 first and move slow work, such as database writes or calls to other services, to a queue.
Do not rely on webhooks alone
If your endpoint is down for longer than the retries, events are missed. Use the API to confirm state when it matters, for example Look up a PIN.