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. Pass it when you redeem a PIN; that redemption's event goes to it.
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 redeemed a PIN with a webhook_id.
Also sent when a PIN you sold is redeemed, wherever that happens, to the PIN's own webhook. terminal_id is the terminal you sent to Redeem (or the till that redeemed the PIN), or null; terminal has its id and name and the id and name of its store, or is null when terminal_id is. fee, fee_paid_by, amount_debited and amount_credited are as in the Redeem response. In test mode transaction_ref is null and store_id is left out.
{
"type": "pin.spent",
"data": {
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"transaction_ref": "3c6bb90c-4dc9-4e01-bdf2-75e91199847d",
"store_id": "5c0a7e3b-8d21-4f96-b4e7-1a9d6c2f8e05",
"terminal_id": "2d7e9a14-6b3c-4f81-9e25-c04a8b71d3f6",
"idempotency_key": "502085ca-c469-4223-825e-3c353b16acad",
"new_pin_mask": "7676-****-****-6608",
"amount": 10,
"balance": 90,
"currency": "NGN",
"mode": "live",
"external_reference": "order_10293",
"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"
},
"fee": 0.6,
"fee_paid_by": "brand",
"amount_debited": 10,
"amount_credited": 9.4
}
}
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 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
}
}
The buyer paid a quote you made with a webhook_id.
The payment is on your issuing balance. quote.sent (or quote.review) follows straight away.
{
"type": "quote.paid",
"data": {
"id": "7d0e4f2a-1c9b-4e83-a5d6-2b8f0c3e9a14",
"reference": "QTE-K7Q2M9XW",
"external_reference": "deal-2041",
"status": "paid",
"mode": "live",
"source": "api",
"catalog_id": "3f8a1c2e-6b4d-4f90-8e7a-5c1d9b2e4f63",
"pin_type": "Grams Fresh Bakes",
"currency": "NGN",
"card_value": 10000,
"quantity": 50,
"discount_percent": 5,
"face_total": 500000,
"price": 475000,
"company": "Acme Ltd",
"message": null,
"pay_url": null,
"delivery": "recipients",
"recipients_count": 50,
"buyer": {
"name": "Bola Ade",
"email": "[email protected]"
},
"cards": null,
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"expires_at": "2026-11-19T22:59:59.000000Z",
"paid_at": "2026-11-06T10:02:41.000000Z",
"created_at": "2026-11-05T09:14:22.000000Z"
}
}
A paid quote's cards started going out.
cards shows their progress when the event was sent; retrieve the quote, or follow the send on your Bulk send page, for later figures.
{
"type": "quote.sent",
"data": {
"id": "7d0e4f2a-1c9b-4e83-a5d6-2b8f0c3e9a14",
"reference": "QTE-K7Q2M9XW",
"external_reference": "deal-2041",
"status": "sent",
"mode": "live",
"source": "api",
"catalog_id": "3f8a1c2e-6b4d-4f90-8e7a-5c1d9b2e4f63",
"pin_type": "Grams Fresh Bakes",
"currency": "NGN",
"card_value": 10000,
"quantity": 50,
"discount_percent": 5,
"face_total": 500000,
"price": 475000,
"company": "Acme Ltd",
"message": null,
"pay_url": null,
"delivery": "recipients",
"recipients_count": 50,
"buyer": {
"name": "Bola Ade",
"email": "[email protected]"
},
"cards": {
"status": "queued",
"total": 50,
"sent": 0,
"failed": 0
},
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"expires_at": "2026-11-19T22:59:59.000000Z",
"paid_at": "2026-11-06T10:02:41.000000Z",
"created_at": "2026-11-05T09:14:22.000000Z"
}
}
A paid quote's cards couldn't be sent.
The payment stays on your issuing balance; our team has been told and contacts you.
{
"type": "quote.review",
"data": {
"id": "7d0e4f2a-1c9b-4e83-a5d6-2b8f0c3e9a14",
"reference": "QTE-K7Q2M9XW",
"external_reference": "deal-2041",
"status": "review",
"mode": "live",
"source": "api",
"catalog_id": "3f8a1c2e-6b4d-4f90-8e7a-5c1d9b2e4f63",
"pin_type": "Grams Fresh Bakes",
"currency": "NGN",
"card_value": 10000,
"quantity": 50,
"discount_percent": 5,
"face_total": 500000,
"price": 475000,
"company": "Acme Ltd",
"message": null,
"pay_url": null,
"delivery": "recipients",
"recipients_count": 50,
"buyer": {
"name": "Bola Ade",
"email": "[email protected]"
},
"cards": null,
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"expires_at": "2026-11-19T22:59:59.000000Z",
"paid_at": "2026-11-06T10:02:41.000000Z",
"created_at": "2026-11-05T09:14:22.000000Z"
}
}
A quote passed expires_at unpaid.
Sent once, within the hour after the quote expires.
{
"type": "quote.expired",
"data": {
"id": "7d0e4f2a-1c9b-4e83-a5d6-2b8f0c3e9a14",
"reference": "QTE-K7Q2M9XW",
"external_reference": "deal-2041",
"status": "expired",
"mode": "live",
"source": "api",
"catalog_id": "3f8a1c2e-6b4d-4f90-8e7a-5c1d9b2e4f63",
"pin_type": "Grams Fresh Bakes",
"currency": "NGN",
"card_value": 10000,
"quantity": 50,
"discount_percent": 5,
"face_total": 500000,
"price": 475000,
"company": "Acme Ltd",
"message": null,
"pay_url": null,
"delivery": "recipients",
"recipients_count": 50,
"buyer": null,
"cards": null,
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"expires_at": "2026-11-19T22:59:59.000000Z",
"paid_at": null,
"created_at": "2026-11-05T09:14:22.000000Z"
}
}
A quote was withdrawn, through the API or on your Campaigns page.
{
"type": "quote.cancelled",
"data": {
"id": "7d0e4f2a-1c9b-4e83-a5d6-2b8f0c3e9a14",
"reference": "QTE-K7Q2M9XW",
"external_reference": "deal-2041",
"status": "cancelled",
"mode": "live",
"source": "api",
"catalog_id": "3f8a1c2e-6b4d-4f90-8e7a-5c1d9b2e4f63",
"pin_type": "Grams Fresh Bakes",
"currency": "NGN",
"card_value": 10000,
"quantity": 50,
"discount_percent": 5,
"face_total": 500000,
"price": 475000,
"company": "Acme Ltd",
"message": null,
"pay_url": null,
"delivery": "recipients",
"recipients_count": 50,
"buyer": null,
"cards": null,
"webhook_id": "ca1d075c-f6b0-4d4a-b498-d126239ee0a4",
"expires_at": "2026-11-19T22:59:59.000000Z",
"paid_at": null,
"created_at": "2026-11-05T09:14:22.000000Z"
}
}
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 payment.