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

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

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
    }
}
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.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
    }
}
quote.paid

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"
    }
}
quote.sent

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"
    }
}
quote.review

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

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"
    }
}
quote.cancelled

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"
    }
}
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 payment.