AfroPin Developers
Postman Sign in Get test keys

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

  1. 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.

  2. 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.

  3. 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:

type
The event type, for example pin.spent.
data
The event's details. Fields differ per event; see the list below. Most events include the webhook_id and the idempotency_key of the API request that caused them (null for events from background checks, such as expiry).
webhook-secret header
The signature: HMAC-SHA256 of the raw request body, keyed with your signing secret, as lowercase hex. Despite its name, it does not contain your secret.

Verify 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.

Hash the body exactly as it arrived. Parsing the JSON and encoding it again can change escaping (for example of slashes) and break the comparison.

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.

pin.sold

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.

Storing PIN codes

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
    }
}
pin.topped_up

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.

Storing PIN codes

{
    "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"
        }
    }
}
pin.spent

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.

Storing PIN codes

{
    "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"
        }
    }
}
pin.code_changed

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.

Storing PIN codes

{
    "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"
    }
}
pin.expired

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
    }
}
pin.moved

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"
    }
}
pin.frozen

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"
    }
}
pin.unfrozen

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.

We send to the customer email you gave, and on WhatsApp too when the PIN has a phone number and 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.