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_idand receive events. Webhooks
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[@]}"
}
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
-
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/walletsRequest
api GET reseller/walletsResponse
{ "ok": true, "data": [ { "amount": 5000, "currency": "NGN" } ] } -
Pick a product to issue
A product is a PIN template you are allowed to sell. Note its
GET /api/v1/reseller/catalog GET /api/v1/reseller/catalog/{product}id: you pass it ascatalog_idwhen issuing. It also tells you the currency, the amounts you can issue (any amount frommintomaxforany_amount, or one of thedenominationsforset_amounts), how many days a PIN lasts (duration), whether it can be topped up and your commission as a percentage insale_commission_percent.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 } } -
Register a purchase location
A purchase location is a store or till where you sell PINs. Its
POST /api/v1/reseller/stores GET /api/v1/reseller/storesidis thestore_idyou can attach to issues and top-ups, so your reports show where each sale happened. If your account has store enforcement switched on,store_idis required on those calls and you must do this step first. Thecountryis an ISO 3166-1 alpha-2 code,stateis the ISO code of a state in that country andpostal_codemust be valid for that country. Locations belong to one mode, so create one with your test key for testing.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 locationid. A location that is unknown, inactive or in the other mode fails withinvalid_store_id. -
Issue a PIN
Issue a PIN for your customer. Your wallet is debited the
POST /api/v1/reseller/pinsamountand 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.emailmust be a real, deliverable address. Addwebhook_id(one of your webhooks, same mode) to follow the PIN with events.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" } }Storepin_id: every later call about this PIN uses it. Givepin_codeto 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. -
Look up the PIN
Fetch the PIN's current balance and status (
GET /api/v1/reseller/pins/{voucher}active,blockedorexpired).past_pin_maskslists every PIN the voucher has had, oldest first, andpin_maskis the one that works now.Request
api GET reseller/pins/9f1c2e7a-4b22-4c10-9a3e-7d2f1b6c8a90Response
{ "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" } } -
Top up the PIN
Add value to a PIN whose product allows it (
POST /api/v1/reseller/pins/top-uptopup: true). The PIN's new balance must stay within the product'sminandmax, and onset_amountsproducts 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.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 hasnew_code_after_top_up: true, every top-up replaces the PIN:new_pin_codeholds the new one, returned only this once. Replace the code you saved and give it to the customer: the old code stops working withpin_code_replaced. Storing PIN codes. -
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
GET /api/v1/reseller/pins/{voucher}/movementsday(any date, for example2026-10-27) and setlimit(20 per page by default). The list is paged: followpaging.nextor passpage, or usepage=allfor everything.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 } } -
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
POST /api/v1/reseller/pins/block POST /api/v1/reseller/pins/unblockpin_blocked. Activate it again when the issue is resolved. Both calls take onlypin_id; calling one on a PIN already in that state returnspin_already_blockedorpin_already_active, and an expired PIN can be neither.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.soldpin.topped_uppin.spentpin.expiredWhat 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
- Complete KYC and ask for live API access to be enabled on your account.
- Create a live API key and swap its public key and secret key into your integration. The requests themselves do not change.
- Create your purchase locations and webhooks again with the live key; test ones are not visible in live mode.
- Fund your live wallet in each currency you will issue, and allow for your commission when reconciling.
Next steps
- Issue a PIN for every field and error of the issue call.
- Redemption locations to show customers where they can spend their PINs.
- Webhooks to receive PIN events and verify their signature.
- Go-live checklist to check your integration before you go live.