AfroPin Developers
Postman Sign in Get test keys

Build your integration

Sell gift cards from your app

Sell AfroPin PINs to your customers: issue a PIN from your wallet, top it up and control it. This guide runs the whole life of a PIN in test mode.

Before you begin

  • Your account type is reseller, and PIN purchase is enabled on it for issuing and top-ups. Without it those calls fail with issuing_disabled.
  • You have a test API key: its public key and secret key, from API keys. Test keys work before KYC approval; live keys need approved KYC and live API access.
  • Your test wallet holds funds in the currency of the product you will issue.
  • Optional: a webhook created in your dashboard, so you can pass its ID as webhook_id and receive events. Webhooks
The key decides the mode, not the URL: a test key puts every request in test mode. Test and live data never mix, including PINs, locations and webhooks. Test PINs move only test balances. Environments

Sign your requests

Every request carries four signature headers and a unique Idempotency-Key. The signature is an HMAC-SHA256, keyed with your secret key, of METHOD.path.timestamp.nonce.sha256(body), where path is the URL path without the leading slash or query string. Authentication explains each part and has code in other languages.

The examples on this page use this shell helper. Paste it into your terminal with your test keys, then run each step as written.

export BASE_URL="https://afropin.com/api/v1"
export KEY_ID="YOUR_TEST_PUBLIC_KEY"
export SECRET="YOUR_TEST_SECRET_KEY"

# api METHOD ENDPOINT [JSON_BODY]: signs the request and sends it with a fresh nonce and Idempotency-Key
api() {
  local method="$1" endpoint="$2" body="${3:-}"
  local ts nonce url_path body_hash signature
  ts=$(date +%s)
  nonce=$(openssl rand -hex 16)
  url_path=$(printf '%s' "$BASE_URL/$endpoint" | sed -E 's#^https?://[^/]+/##; s#\?.*$##')
  body_hash=$(printf '%s' "$body" | openssl dgst -sha256 -hex | sed 's/^.* //')
  signature=$(printf '%s' "$method.$url_path.$ts.$nonce.$body_hash" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
  local data=()
  [ -n "$body" ] && data=(--data-raw "$body")
  curl -sS -X "$method" "$BASE_URL/$endpoint" \
    -H "X-Key-Id: $KEY_ID" \
    -H "X-Timestamp: $ts" \
    -H "X-Nonce: $nonce" \
    -H "X-Signature: $signature" \
    -H "Idempotency-Key: $(openssl rand -hex 16)" \
    -H "Content-Type: application/json" \
    "${data[@]}"
}
An Idempotency-Key can be used once. Sending it again is rejected with idempotency_key_reused; the first response is not replayed. If an issue or top-up times out, check the PIN's transactions for your external_reference before retrying with a new key. Your external_reference is what stops a sale being made twice: reusing it fails with duplicate_external_reference.

Walk through the life of a PIN

  1. Check your balance

    Start with a read-only call to prove your signing works. The response lists one wallet per currency on your account, for the mode of your key. Issuing and topping up a PIN draws from the wallet in the product's currency, so it must hold enough test funds; if your test wallet is empty, contact support to fund it.

    GET /api/v1/reseller/wallets

    Request

    api GET reseller/wallets

    Response

    {
        "ok": true,
        "data": [
            {
                "amount": 5000,
                "currency": "NGN"
            }
        ]
    }
  2. Pick a product to issue

    A product is a PIN template you are allowed to sell. Note its id: you pass it as catalog_id when issuing. It also tells you the currency, the amounts you can issue (any amount from min to max for any_amount, or one of the denominations for set_amounts), how many days a PIN lasts (duration), whether it can be topped up and your commission as a percentage in sale_commission_percent.

    GET /api/v1/reseller/catalog GET /api/v1/reseller/catalog/{product}

    Request

    api GET "reseller/catalog?limit=20"

    Response (key fields)

    {
        "ok": true,
        "data": [
            {
                "id": "3d7e9a1b-2c4f-4e6a-8b0d-9f1e2a3b4c5d",
                "pin_type_id": "c2b9e1d4-5f6a-47b8-9c0d-1e2f3a4b5c6d",
                "name": "Everyday Shopping PIN",
                "currency": "NGN",
                "min": 5,
                "max": 500,
                "amount_rule": "any_amount",
                "denominations": [],
                "duration": 90,
                "can_top_up": true,
                "spend_rule": "partial",
                "new_code_after_top_up": false,
                "new_code_after_spend": true,
                "sale_commission_percent": 2
            }
        ],
        "paging": {
            "page": 1,
            "pages": 1,
            "per_page": 20,
            "total": 1,
            "next": null,
            "previous": null
        }
    }
  3. Register a purchase location

    A purchase location is a store or till where you sell PINs. Its id is the store_id you can attach to issues and top-ups, so your reports show where each sale happened. If your account has store enforcement switched on, store_id is required on those calls and you must do this step first. The country is an ISO 3166-1 alpha-2 code, state is the ISO code of a state in that country and postal_code must be valid for that country. Locations belong to one mode, so create one with your test key for testing.

    POST /api/v1/reseller/stores GET /api/v1/reseller/stores

    Request

    api POST reseller/stores '{
        "title": "Ikeja City Mall Kiosk",
        "address": "174/194 Obafemi Awolowo Way",
        "latitude": 6.6141,
        "longitude": 3.3573,
        "city": "Ikeja",
        "state": "LA",
        "postal_code": "100271",
        "country": "NG"
    }'

    Response

    {
        "ok": true,
        "data": {
            "id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
            "title": "Ikeja City Mall Kiosk",
            "latitude": "6.6141",
            "longitude": "3.3573",
            "address": "174/194 Obafemi Awolowo Way",
            "country": "NG",
            "city": "Ikeja",
            "state": "LA",
            "postal_code": "100271"
        }
    }
    Store the location id. A location that is unknown, inactive or in the other mode fails with invalid_store_id.
  4. Issue a PIN

    Issue a PIN for your customer. Your wallet is debited the amount and your commission is credited to your commission balance. Test mode has no fixed PINs: you issue real PINs against your test wallet, with real codes and check digits. email must be a real, deliverable address. Add webhook_id (one of your webhooks, same mode) to follow the PIN with events.

    POST /api/v1/reseller/pins

    Request

    api POST reseller/pins '{
        "catalog_id": "3d7e9a1b-2c4f-4e6a-8b0d-9f1e2a3b4c5d",
        "amount": 50,
        "external_reference": "sale-2001",
        "first_name": "Jane",
        "last_name": "Doe",
        "email": "[email protected]",
        "phone_code": "NG",
        "phone": "08031234567",
        "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
        "webhook_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d"
    }'

    Response

    {
        "ok": true,
        "data": {
            "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90",
            "webhook_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
            "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
            "transaction_ref": "3c6bb90c-4dc9-4e01-bdf2-75e91199847d",
            "pin_code": "5260-1815-9083-0164",
            "pin_mask": "5260-****-****-0164",
            "amount": 50,
            "balance": 50,
            "currency": "NGN",
            "mode": "test",
            "customer": {
                "first_name": "Jane",
                "last_name": "Doe",
                "email": "[email protected]",
                "phone": "+2348031234567"
            },
            "status": "active",
            "external_reference": "sale-2001",
            "expires_at": "2027-01-25T10:02:11.000000Z",
            "sold_at": "2026-10-27T10:02:11.000000Z"
        }
    }
    Store pin_id: every later call about this PIN uses it. Give pin_code to your customer and save it securely first: we never store PIN codes, so this is the only response that contains the full code, later calls return only masks, and a lost code can never be recovered. Storing PIN codes.
  5. Look up the PIN

    Fetch the PIN's current balance and status (active, blocked or expired). past_pin_masks lists every PIN the voucher has had, oldest first, and pin_mask is the one that works now.

    GET /api/v1/reseller/pins/{voucher}

    Request

    api GET reseller/pins/9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90

    Response

    {
        "ok": true,
        "data": {
            "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90",
            "webhook_id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
            "balance": 50,
            "currency": "NGN",
            "pin_mask": "5260-****-****-0164",
            "past_pin_masks": [
                "5260-****-****-0164"
            ],
            "mode": "test",
            "customer": {
                "first_name": "Jane",
                "last_name": "Doe",
                "email": "[email protected]",
                "phone": "+2348031234567"
            },
            "status": "active",
            "expires_at": "2027-01-25 10:02:11",
            "sold_at": "2026-10-27T10:02:11.000000Z"
        }
    }
  6. Top up the PIN

    Add value to a PIN whose product allows it (topup: true). The PIN's new balance must stay within the product's min and max, and on set_amounts products the top-up amount must be one of the denominations. Your wallet is debited the amount and your commission is credited again. A disabled or expired PIN cannot be topped up.

    POST /api/v1/reseller/pins/top-up

    Request

    api POST reseller/pins/top-up '{
        "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90",
        "amount": 25,
        "external_reference": "sale-2002",
        "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e"
    }'

    Response

    {
        "ok": true,
        "data": {
            "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90",
            "transaction_ref": "a41d7c2e-9b3f-4e8a-b6d1-0c5f2e7a9b31",
            "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
            "new_pin_code": null,
            "new_pin_mask": null,
            "amount": 25,
            "balance": 75,
            "currency": "NGN",
            "mode": "test",
            "customer": {
                "first_name": "Jane",
                "last_name": "Doe",
                "email": "[email protected]",
                "phone": "+2348031234567"
            },
            "status": "active",
            "external_reference": "sale-2002"
        }
    }
    If the product has new_code_after_top_up: true, every top-up replaces the PIN: new_pin_code holds the new one, returned only this once. Replace the code you saved and give it to the customer: the old code stops working with pin_code_replaced. Storing PIN codes.
  7. List the PIN's transactions

    Every movement of value on the PIN, newest first: the issue and top-ups as credits, and brand redemptions as debits. Filter by day (any date, for example 2026-10-27) and set limit (20 per page by default). The list is paged: follow paging.next or pass page, or use page=all for everything.

    GET /api/v1/reseller/pins/{voucher}/movements

    Request

    api GET "reseller/pins/9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90/movements?limit=20"

    Response

    {
        "ok": true,
        "data": [
            {
                "id": "a41d7c2e-9b3f-4e8a-b6d1-0c5f2e7a9b31",
                "external_reference": "sale-2002",
                "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
                "amount": 25,
                "currency": "NGN",
                "balance_before": 50,
                "balance_after": 75,
                "direction": "in",
                "type": "pin_top_up",
                "status": "success",
                "mode": "test",
                "created_at": "2026-10-27T10:05:40.000000Z"
            },
            {
                "id": "3c6bb90c-4dc9-4e01-bdf2-75e91199847d",
                "external_reference": "sale-2001",
                "store_id": "5b7c9d1e-3f2a-4b6c-8d0e-1f2a3b4c5d6e",
                "amount": 50,
                "currency": "NGN",
                "balance_before": 0,
                "balance_after": 50,
                "direction": "in",
                "type": "pin_sale",
                "status": "success",
                "mode": "test",
                "created_at": "2026-10-27T10:02:11.000000Z"
            }
        ],
        "paging": {
            "page": 1,
            "pages": 1,
            "per_page": 20,
            "total": 2,
            "next": null,
            "previous": null
        }
    }
  8. Disable and re-activate the PIN

    Disable a PIN to stop it being used, for example when a customer reports it lost. While it is disabled, top-ups and brand redemptions fail with pin_blocked. Activate it again when the issue is resolved. Both calls take only pin_id; calling one on a PIN already in that state returns pin_already_blocked or pin_already_active, and an expired PIN can be neither.

    POST /api/v1/reseller/pins/block POST /api/v1/reseller/pins/unblock

    Request

    api POST reseller/pins/block '{ "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90" }'
    
    api POST reseller/pins/unblock '{ "pin_id": "9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90" }'

    Response

    {
        "ok": true,
        "data": null
    }

Follow PINs with webhooks

Events about a PIN go to the webhook set on it: the webhook_id passed when it was issued, or one set later with Change the webhook on a PIN. A PIN without a webhook sends no events.

pin.sold
A PIN was issued.
pin.topped_up
A PIN was topped up.
pin.spent
A brand redeemed the PIN. Live mode only, because test-mode redemptions use fixed test PINs. If the product issues a new code after each use, pin_code carries the new PIN code to give your customer.
pin.expired
The PIN expired. Any balance left on it is not returned to your wallet.

What your customers can do themselves

Every gift card email and wallet pass links to the customer's card account, whoever sold the card. There, after signing in with a code sent to their email, they can check the balance, freeze the card, get a new code (one a day), keep the same code or set a daily limit, top it up by card (the payment goes to your wallet as a normal top-up), transfer it to someone else, and make family cards funded from it. Your webhook hears each change: pin.topped_up, pin.code_changed (source cardholder), pin.frozen, pin.unfrozen and pin.moved. Webhooks

Handle failures

Failed requests return "ok": false and an error object with a stable code; branch on the code, not the translated message. Validation errors also list each field in error.fields. Drive these on purpose in test mode, for example by issuing more than your wallet holds or disabling a PIN and then topping it up.

Code HTTP When
validation_error 422 A required field is missing or the wrong type, or store_id is missing while store enforcement is on.
wrong_account_type 403 The key belongs to an account that is not a reseller.
issuing_disabled 403 Issuing and top-ups are switched off for your account. Contact support.
catalog_item_not_found 404 No active product with that catalog_id.
pin_type_inactive 403 The product's template is switched off.
product_inactive 403 The product is switched off.
amount_too_small 422 The amount, or the PIN's balance after a top-up, is below the minimum.
amount_too_large 422 The amount, or the PIN's balance after a top-up, is above the maximum.
amount_not_offered 422 A set_amounts product and the amount is not one of its denominations.
currency_not_supported 403 You have no wallet in the product's currency.
wallet_balance_low 402 Your wallet holds less than the amount.
duplicate_external_reference 422 You already used this external_reference.
invalid_webhook_id 422 / 404 No webhook with that ID in this mode (422 on issue, 404 on Change the webhook on a PIN).
invalid_store_id 422 No active purchase location with that ID in this mode.
pin_not_found 404 No PIN with that pin_id on your account in this mode.
topup_not_allowed 403 The product does not allow top-ups.
pin_blocked 403 The PIN is disabled.
pin_expired 403 The PIN has expired.
pin_already_active 409 Activating a PIN that is already active.
pin_already_blocked 409 Disabling a PIN that is already disabled.
rate_limited 429 Another request from your account is still being processed. Retry after a moment.

Signature and key failures (invalid_api_key, invalid_signature, missing_auth_headers, timestamp_out_of_window, nonce_replayed) and Idempotency-Key failures apply to every endpoint. All error codes →

Test mode and live mode

Test mode Live mode
Access Any test key, before KYC. Approved KYC and live API access.
PINs Real PINs flagged as test, issued against your test wallet. Codes, check digits and matching work as in live. Real PINs against your live wallet.
Brand redemptions Not possible: brands' test redemptions use fixed test PINs, never yours. Brands with a product for the PIN's template can redeem it.
Low balance email Not sent. Sent to your account email, at most once a day, when a wallet drops below its warning level.

Go live

  1. Complete KYC and ask for live API access to be enabled on your account.
  2. Create a live API key and swap its public key and secret key into your integration. The requests themselves do not change.
  3. Create your purchase locations and webhooks again with the live key; test ones are not visible in live mode.
  4. Fund your live wallet in each currency you will issue, and allow for your commission when reconciling.

Next steps