API Reference
Use the RibiRewards Reward API to connect your product to reward catalogue, campaign and recipient issuance capabilities across supported African markets. Start with the quick guide below, then use the endpoint reference when you are ready to build.
Overview
The RibiRewards Reward API uses the existing RewardsCard Partner API, a REST API served over HTTPS. All request and response bodies are JSON. It is designed for server-to-server integration — your backend calls the API, and your frontend never needs to know about it.
Pick categories
Fetch just a country's category list — no nested product dump. Tick which ones a campaign should be scoped to, or skip this for the full catalogue.
Create a campaign
One call sets the name, country, category scope, and a single amount that every code issued under it will share.
Submit recipients
Send a batch of names and emails against the campaign. Each recipient is emailed their code directly — there's no separate delivery step on your side.
Quick start
Get your first card issued in under 5 minutes.
- 1
Get an API key
Submit a key request or email tech@ribirewards.com. Keys are provisioned manually and are active within one business day.
- 2
Check available categories
Call
GET /v1/categories?country=NGwith your key. You will receive just the category list for that country — no product dump. - 3
Create a campaign
Call
POST /v1/campaignswith a name, the country, an amount, and — if you want to scope it — thecategoryIdsfrom the previous step. Save theidthis returns — this is your campaignId. - 4
Submit recipients
Call
POST /v1/campaigns/:campaignId/recipientswith a name + email for each person, and an Idempotency-Key. Every recipient is emailed their code directly — there is no separate delivery step. - 5
That's it
Each recipient opens shop.rewardscardafrica.com, enters their code, and shops within whatever categories the campaign was scoped to. Check GET /v1/campaigns/:campaignId/redemptions to see who's actually redeemed.
Authentication
Every request (except POST /v1/request-api-key) requires your API key as a request header:
X-API-Key: your_api_key_hereHow authentication works
On every authenticated request, the API verifies your key against the partner registry. The key must exist and have status ACTIVE. A BLOCKED key is treated the same as an invalid key — you receive a 422 either way.
- Keys are long-lived — there is no expiry mechanism. Rotate your key by contacting tech@ribirewards.com if you believe it has been compromised.
- Your API key is redacted from server logs at the network layer — it is never written to log storage, even at debug level.
- Store your key in an environment variable or secrets manager. Do not hard-code it, commit it to a repository, or pass it through a browser.
- If you need to call this API from a browser context, contact us to add your origin to the CORS allowlist. See the CORS & Security section.
Key status reference
| Status | Behaviour |
|---|---|
| ACTIVE | All endpoints work normally. |
| BLOCKED | All authenticated requests return 422. Contact tech@ribirewards.com to resolve. |
Idempotency
POST /v1/campaigns/:campaignId/recipients requires an Idempotency-Key header on every call. Omitting it returns a 400 immediately, before any issuance is attempted.
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000Why this matters
Network timeouts are normal. Without idempotency, a timeout leaves you not knowing whether a card was issued — and a naive retry would issue a second one. With idempotency, you always retry with the same key, and the API returns the original result regardless of whether the first attempt succeeded.
- The Idempotency-Key header is required on every POST /v1/campaigns/:campaignId/recipients call. A 400 is returned if it is missing.
- Keys are scoped per partner account. The same key value sent by two different partners does not conflict.
- Keys expire after 24 hours. After expiry, the same key value can be reused — it will be treated as a new request.
- Only successful and client-error responses are cached (status < 500). A 500 or 504 is never cached — you can safely retry.
- The cached response is returned as-is, including the original status code. A cached 200 returns 200, not 201.
- Backed by Redis when configured, so this is safe across multiple instances — falls back to an in-memory store only in environments without Redis provisioned (e.g. local development).
Recommended approach
// Generate one key per issuance attempt.
// Tie it to your own order ID so you can reconstruct it on retry.
import { randomUUID } from "crypto";
const idempotencyKey = randomUUID();
// Store idempotencyKey alongside your order before calling the API.
// If the call times out, retrieve your stored key and retry with it.Rate limits
The API uses two-tier rate limiting on authenticated routes. All rate limit headers follow the standard RateLimit-* format and are present on every response.
| Tier | Scope | Window | Limit | Applies to |
|---|---|---|---|---|
| Edge (IP) | Per IP address | 60 seconds | 300 requests | All endpoints — runs before authentication |
| Partner (general) | Per API key | 60 seconds | 120 requests | All authenticated endpoints |
| Partner (redeem) | Per API key | 60 seconds | 20 requests | POST /v1/campaigns/:campaignId/recipients only |
| Key request | Per IP address | 15 minutes | 5 requests | POST /v1/request-api-key only |
| Dashboard login | Per IP address | 15 minutes | 10 requests | POST /v1/dashboard-login only |
Daily spend cap
In addition to request-rate limits, every partner account has a daily ceiling on total redemption value per currency. This is separate from the request rate — you can be well within the rate limit and still hit the spend cap if you issue enough high-value cards in one day.
- The cap is per-currency and per-partner — spending in NGN does not count against your ZAR cap.
- The cap resets automatically at midnight UTC.
- When the cap is hit, POST /v1/campaigns/:campaignId/recipients returns 429 with a message identifying the spend cap (not the request rate) as the reason — checked as one total for the whole submission before anything is sent.
- Spend is only counted on confirmed successful issuance — a failed or timed-out attempt never counts against the cap.
- Contact tech@ribirewards.com to raise your cap.
Handling 429 responses
- Read the Retry-After response header — it contains the number of seconds to wait before retrying.
- Always implement back-off. Do not poll without a delay.
- Check the message field to distinguish a rate-limit 429 from a spend-cap 429 — the messages are different.
CORS & Security
CORS
- Server-to-server calls (no Origin header) are never blocked by CORS — this is correct browser behaviour and is intentional.
- Browser-originated cross-site calls are checked against an allowlist. By default the allowlist is empty — this API is designed for server-to-server use.
- If you need to call this API from a browser context (e.g. your own dashboard), contact tech@ribirewards.com to have your origin added to the allowlist.
Security headers
- HTTPS is enforced. The HSTS header (max-age=31536000; includeSubDomains; preload) is set on every response.
- The X-Powered-By header is not present — framework fingerprinting is disabled.
- Request bodies are capped at 100kb. Requests exceeding this limit are rejected before reaching validation.
- Every request has a 15-second server-side timeout. If a downstream call hangs, the server closes the connection and returns 504 rather than leaving it open indefinitely.
- Auth headers (X-API-Key, Authorization) and cookies are redacted from server logs. Request and response bodies are never logged.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/reward-card-catalogue | Required | Fetch the product catalogue for a country |
| GET | /v1/categories | Required | Category list for a country — no product dump |
| POST | /v1/campaigns | Required | Create a campaign — country, category scope, one amount |
| POST | /v1/campaigns/:campaignId/recipients | Required | Submit recipients (name + email) — the actual send |
| GET | /v1/campaigns/:campaignId/redemptions | Required | Redemption activity for one campaign |
| GET | /v1/wallet-balance | Required | Get your current wallet balance |
| GET | /v1/reward-card-redemptions | Required | List all cards issued by your account |
| POST | /v1/request-api-key | None | Request API access (no key required) |
| POST | /v1/dashboard-login | None | Log in with a dashboard key (not an API key) |
| GET | /v1/dashboard/wallet-balance | Required | Wallet balance, using a dashboard key |
| GET | /v1/dashboard/reward-card-redemptions | Required | List redemptions, using a dashboard key |
/v1/reward-card-catalogue Auth requiredReturns the full product catalogue for one supported country, grouped by category. Only products with status ACTIVE and in-stock are included. Categories with no qualifying products are excluded from the response entirely.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| country | string | Required | The country to fetch the catalogue for, e.g. NG, ZA, KE.Must be a 2-letter ISO 3166-1 alpha-2 code. Returns 422 if missing, 400 if unsupported. |
Markup
If your account has a catalogue markup percentage configured, it is applied server-side before the response is built. The amount field on every product already reflects the marked-up price — you do not need to apply markup yourself, and you never see our base cost.
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/reward-card-catalogue?country=NG" \
-H "X-API-Key: your_api_key_here"const response = await fetch(
"https://api.rewardscardafrica.com/v1/reward-card-catalogue?country=NG",
{ headers: { "X-API-Key": "your_api_key_here" } }
);
const { country, currency, categories } = await response.json();import requests
resp = requests.get(
"https://api.rewardscardafrica.com/v1/reward-card-catalogue",
params={"country": "NG"},
headers={"X-API-Key": "your_api_key_here"},
)
resp.raise_for_status()
data = resp.json()Example response
{
"country": "Nigeria",
"countryCode": "NG",
"currency": "NGN",
"categories": [
{
"id": "cat_a1b2c3d4-e5f6-...",
"title": "Airtime & Mobile Data",
"image": "https://cdn.example.com/categories/airtime.jpg",
"products": [
{
"id": "prod_550e8400-e29b-...",
"title": "MTN Nigeria ₦500 Airtime",
"description": "Instant airtime top-up for any MTN Nigeria number.",
"amount": "500.00",
"currency": "NGN",
"deliveryDays": "Delivered within a few hours",
"image": "https://cdn.example.com/products/mtn-500.jpg",
"brand": {
"id": "brand_9c821f3a-...",
"title": "MTN Nigeria",
"image": "https://cdn.example.com/brands/mtn.png"
}
}
]
}
]
}Response fields — top level
| Field | Type | Nullable | Description |
|---|---|---|---|
| country | string | No | Full country name matching the requested country code. |
| countryCode | string | No | The 2-letter code you sent in the request, echoed back. |
| currency | string | No | ISO 4217 currency code for this country. All product amounts in this response are in this currency. |
| categories | array | No | Array of category objects, each containing its products. Empty arrays are never present — a category only appears if it has at least one qualifying product. |
Response fields — category object
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string (UUID) | No | Stable category identifier. Safe to store and use as a reference key — categories are not reassigned or deleted, only deprecated. |
| title | string | Yes | Display name of the category, e.g. 'Airtime & Mobile Data'. Null in rare cases where a category record exists but has no title yet — always null-check before rendering. |
| image | string (URL) | Yes | CDN-hosted category image URL. Null when no image has been uploaded for this category. Do not hotlink — download and serve from your own CDN if you need guaranteed availability. |
| products | array | No | All active, in-stock products for this category in the requested country. Empty arrays are never returned — categories with zero qualifying products are filtered out before the response is built. |
Response fields — product object
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string (UUID) | No | Stable product identifier. No longer passed directly in any API call — see campaignId in the glossary for how issuance actually works as of 25 Aug 2026. |
| title | string | Yes | Product display name, e.g. 'MTN Nigeria ₦500 Airtime'. Null in rare cases — null-check before rendering. |
| description | string | Yes | Short product description. Null when not supplied by the catalogue source. |
| amount | string (decimal) | Yes | Face value of the product expressed as a decimal string, e.g. '500.00'. Always a string — parse with parseFloat() before arithmetic. If your account has a catalogue markup configured, this value is already marked up; you do not need to apply markup yourself. Null in very rare legacy records. |
| currency | string (ISO 4217) | Yes | ISO 4217 currency code, e.g. 'NGN', 'ZAR'. Always matches the currency of the requested country — products cannot appear in a catalogue for the wrong currency. |
| deliveryDays | string | Yes | Human-readable delivery expectation. This is a full display string, not a number — values include: 'Delivered within a few hours' (instant/voucher products), 'Usually delivered within 1–7 business days' (shop and ship), 'Concierge — booking confirmed by team' (experience products). Render as-is, do not append 'days' or 'business days'. |
| image | string (URL) | Yes | CDN-hosted product image URL. Null when no image exists. Apply the same hotlinking caution as category images. |
| brand | object | Yes | The brand this product belongs to. Null in very rare cases where a product record has no associated brand. Always null-check the whole object before accessing sub-fields. |
| brand.id | string (UUID) | No | Stable brand identifier. |
| brand.title | string | Yes | Brand display name, e.g. 'MTN Nigeria'. |
| brand.image | string (URL) | Yes | CDN-hosted brand logo URL. Null when no logo has been uploaded. |
Notes
- Product IDs (id) are stable UUIDs. Safe to store as a reference to a specific product.
- amount is always a string, not a number. Parse with parseFloat() or your language's decimal equivalent before any arithmetic.
- deliveryDays is a full display string — do not append 'days' or modify it. Render it exactly as returned.
- image URLs may be null at any level. Always null-check before rendering — do not assume an image exists.
- The catalogue can change between calls (new products added, products going out of stock). Do not cache it indefinitely — refresh at least once per session or before showing it to a customer.
- Country coverage expands periodically without notice. Re-confirm supported countries against GET /v1/reward-card-catalogue rather than hardcoding the list.
- Every product returned here is voucher-fulfilled unless RibiRewards has separately agreed a physical/shop-and-ship arrangement with you. There is no field on the product object to filter or detect merchandise/physical products — that distinction isn't API-addressable today. If your integration needs to offer physical goods, contact tech@ribirewards.com to scope that separately; the recipient selects the product and delivery address on RibiRewards' own redemption page in that case, not inside your platform.
/v1/categories Auth requiredReturns just the categories available in a country — id, title, image. Deliberately not the full catalogue: no nested products. Use this to build a category picker when creating a campaign, not GET /v1/reward-card-catalogue, which returns every product nested under every category.
Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
| country | string | Required | The country to list categories for.Must be a 2-letter ISO 3166-1 alpha-2 code matching a supported country. |
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/categories?country=NG" \
-H "X-API-Key: your_api_key_here"const resp = await fetch(
"https://api.rewardscardafrica.com/v1/categories?country=NG",
{ headers: { "X-API-Key": "your_api_key_here" } }
);
const { categories } = await resp.json();import requests
resp = requests.get(
"https://api.rewardscardafrica.com/v1/categories",
params={"country": "NG"},
headers={"X-API-Key": "your_api_key_here"},
)
resp.raise_for_status()
categories = resp.json()["categories"]Example response
{
"country": "Nigeria",
"countryCode": "NG",
"categories": [
{ "id": "9f1c2e3a-1234-4a5b-8c9d-abcdef123456", "title": "Dining", "image": "https://cdn.rewardscardafrica.com/categories/dining.jpg" },
{ "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "title": "Beauty", "image": "https://cdn.rewardscardafrica.com/categories/beauty.jpg" }
]
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string (UUID) | No | Category identifier — this is what you pass in categoryIds when creating a campaign. |
| title | string | No | Display name of the category, e.g. 'Dining'. Returned only for categories that have at least one active, in-stock product in the requested country. |
| image | string (URL) | Yes | CDN-hosted category image. Null when no image has been uploaded. |
/v1/campaigns Auth requiredCreates a campaign — a name, a country, a category scope, and one amount that applies to every code issued under it. This replaces choosing a specific product per recipient: pick categories once here, then submit as many recipients as you like against the campaignId this returns. The partner attached to the campaign is always your own authenticated account — never a field you set yourself.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Required | Your own label for the campaign, e.g. 'August staff rewards'. Never shown to a recipient.Non-empty. |
| country | string | Required | Every code issued under this campaign is for this country.Must be a 2-letter ISO 3166-1 alpha-2 code matching a supported country. |
| amount | number | Required | The value of every code issued under this campaign. Set once here — there is no per-recipient amount.Must be a positive number. |
| categoryIds | array of string (UUID) | Optional | Category IDs from GET /v1/categories. Omit for the full catalogue — recipients see every category available in the campaign's country.Must be an array if present. |
Example request
curl -X POST "https://api.rewardscardafrica.com/v1/campaigns" \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "August staff rewards",
"country": "NG",
"amount": 500,
"categoryIds": ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"]
}'const response = await fetch(
"https://api.rewardscardafrica.com/v1/campaigns",
{
method: "POST",
headers: {
"X-API-Key": "your_api_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "August staff rewards",
country: "NG",
amount: 500,
categoryIds: ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"], // optional — omit for the full catalogue
}),
}
);
const { data } = await response.json();
const campaignId = data.id;import requests
resp = requests.post(
"https://api.rewardscardafrica.com/v1/campaigns",
headers={
"X-API-Key": "your_api_key_here",
"Content-Type": "application/json",
},
json={
"name": "August staff rewards",
"country": "NG",
"amount": 500,
"categoryIds": ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"], # optional
},
)
resp.raise_for_status()
campaign_id = resp.json()["data"]["id"]Example response
{
"message": "Campaign created.",
"data": {
"id": "9f1c2e3a-1234-4a5b-8c9d-abcdef123456",
"name": "August staff rewards",
"country": "NG",
"amount": "500.00",
"categoryIds": ["a1b2c3d4-5678-90ab-cdef-1234567890ab"]
}
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string (UUID) | No | Campaign identifier. This is what you pass as campaignId when submitting recipients or fetching campaign-scoped redemptions. |
| name | string | No | Your own label for the campaign, e.g. 'TLC x MTN Ghana, Aug 2026'. Never shown to a recipient — for your own reference and reporting only. |
| country | string (ISO 3166-1 alpha-2) | No | The country every code in this campaign is issued for. Set once at creation, applies to every recipient submitted against it. |
| amount | string (decimal) | No | The value of every code issued under this campaign. Set once at creation — there is no per-recipient amount. |
| categoryIds | array | null | Yes | Category IDs this campaign's codes are scoped to. Null or empty means unscoped — recipients see the full catalogue for the campaign's country. |
| source | string | null | Yes | 'API' or 'DASHBOARD' — which auth path created this campaign. Set automatically, not something you pass. Null for campaigns created before this field existed. |
/v1/campaigns/:campaignId/recipients Auth requiredIdempotency-Key requiredThe actual send. Submit up to 1000 recipients (name + email only — no per-row amount, the campaign's amount applies to every code) against a campaign you already created. Each recipient gets emailed their code directly — there is no separate delivery step. A malformed row never fails the whole submission; check the per-row results array.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| recipients | array of { name, emailAddress } | Required | Each entry needs a name and a valid emailAddress. Nothing else — amount and category scope come from the campaign.Non-empty array. Maximum 1000 entries. |
Required headers
| Header | Value | Notes |
|---|---|---|
| X-API-Key | your_api_key_here | Required on all authenticated endpoints. |
| Idempotency-Key | UUID v4 per attempt | Required — returns 400 if missing. A retried submission with the same key does not resend the batch. |
Example request
curl -X POST "https://api.rewardscardafrica.com/v1/campaigns/9f1c2e3a-1234-4a5b-8c9d-abcdef123456/recipients" \
-H "X-API-Key: your_api_key_here" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{ "name": "Lerisha Naidoo", "emailAddress": "lerisha@example.com" },
{ "name": "Sipho Dube", "emailAddress": "sipho@example.com" }
]
}'import { randomUUID } from "crypto"; // Node.js
const campaignId = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456";
const response = await fetch(
`https://api.rewardscardafrica.com/v1/campaigns/${campaignId}/recipients`,
{
method: "POST",
headers: {
"X-API-Key": "your_api_key_here",
"Idempotency-Key": randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
recipients: [
{ name: "Lerisha Naidoo", emailAddress: "lerisha@example.com" },
{ name: "Sipho Dube", emailAddress: "sipho@example.com" },
],
}),
}
);
const { results, generatedCards } = await response.json();
// results has one entry per row — check status: "SENT" | "FAILED" per row,
// a partial failure does not fail the whole submission.import uuid, requests
campaign_id = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456"
resp = requests.post(
f"https://api.rewardscardafrica.com/v1/campaigns/{campaign_id}/recipients",
headers={
"X-API-Key": "your_api_key_here",
"Idempotency-Key": str(uuid.uuid4()),
"Content-Type": "application/json",
},
json={
"recipients": [
{"name": "Lerisha Naidoo", "emailAddress": "lerisha@example.com"},
{"name": "Sipho Dube", "emailAddress": "sipho@example.com"},
],
},
)
resp.raise_for_status()
data = resp.json()
results = data["results"]
generated_cards = data["generatedCards"]Example response
{
"message": "2 of 2 codes sent.",
"results": [
{ "row": 1, "emailAddress": "lerisha@example.com", "status": "SENT", "cardNumber": "RWDNG-8847-2291-XKQP" },
{ "row": 2, "emailAddress": "sipho@example.com", "status": "SENT", "cardNumber": "RWDNG-1122-3344-ABCD" }
],
"generatedCards": [
{ "cardNumber": "RWDNG-8847-2291-XKQP", "emailAddress": "lerisha@example.com", "amount": "500.00", "currency": "NGN" },
{ "cardNumber": "RWDNG-1122-3344-ABCD", "emailAddress": "sipho@example.com", "amount": "500.00", "currency": "NGN" }
]
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| message | string | No | Summary of the submission, e.g. '48 of 50 codes sent.' Reflects results, not just acceptance — a 200 response can still contain per-row failures. |
| results | array | No | One entry per row submitted, in the same order as the request. Each has row (1-indexed), emailAddress, status ('SENT' or 'FAILED'), and — on failure — reason. A malformed row never fails the whole submission. |
| generatedCards | array | No | One entry per successfully issued code: cardNumber, emailAddress, amount, currency. Only includes SENT rows — cross-reference against results if you need to see failures alongside successes. |
Per-row validation and dedup
- Deduplication happens per row within a campaign recipient submission — if the recipient's email already exists with an ACTIVE account, that account is reused rather than creating a duplicate.
- A currency mismatch (the recipient's existing account is a different currency than the campaign's country) fails that row with a clear reason, rather than silently issuing the wrong currency.
- There is no cross-request deduplication by design — submitting the same CSV twice against the same campaign issues a second batch of codes. Use a distinct Idempotency-Key per submission to guard against accidental retries.
Timeout and retry behaviour
- The API has a 15-second server-side timeout. If a response does not arrive within 15 seconds, the server closes the connection and returns 504.
- On a 504, some recipients in the batch may have already been sent — the internal call may have completed the submission before the timeout was hit on your end.
- Always retry a 504 with the same Idempotency-Key rather than a fresh one — this returns the original result if the submission actually completed, instead of sending the batch a second time.
- 500 responses are never cached by the idempotency store. You can always retry a 500.
Daily spend cap
- Checked as ONE total for the whole submission — the campaign's amount × the number of recipients — before any code is generated, not per-recipient afterward.
- If the total would exceed your cap, the entire submission is rejected with 429 and nothing is sent. Reduce the batch size or contact tech@ribirewards.com to raise your limit.
/v1/reward-cards/generate Auth requiredGenerates a batch of codes with no named recipient yet — for you to distribute yourself (CSV, your own UI, however fits your flow). No email is sent. This is the counterpart to submitting recipients above: use that endpoint when you already know who's getting a code, use this one when you just want a batch of codes in hand. Every code, either way, belongs to a campaign — there is no standalone/unscoped issuance path.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| quantity | integer | Required | Number of codes to generate in this batch.1–5,000 |
| country | string (ISO 3166-1 alpha-2) | Required | Country these codes are issued for.Must be a supported country code |
| amount | number | Required | Value of each code in this batch.Positive number |
| campaignId | string (UUID) | Required | Every batch belongs to a campaign — this field is required, not optional.Must be a campaign you own |
| categoryIds | array | omit | Optional | Re-picked on every call, not inherited from the campaign's original scope. Omit or send empty for the full catalogue.Category IDs from GET /v1/categories |
| note | string | Optional | For your own records only — not reviewed by anyone.Up to 1000 characters |
Example request
curl -X POST "https://api.rewardscardafrica.com/v1/reward-cards/generate" \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"quantity": 10,
"country": "NG",
"amount": 500,
"campaignId": "9f1c2e3a-1234-4a5b-8c9d-abcdef123456",
"categoryIds": ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"]
}'const campaignId = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456";
const response = await fetch(
"https://api.rewardscardafrica.com/v1/reward-cards/generate",
{
method: "POST",
headers: {
"X-API-Key": "your_api_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
quantity: 10,
country: "NG",
amount: 500,
campaignId, // required — every batch belongs to a campaign
categoryIds: ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"], // optional, re-picked each call
}),
}
);
const { codes } = await response.json();
// codes: [{ rewardCardId, cardNumber, serialNumber, amount, currency, expiredAt }]
// No email is sent — cardNumber is yours to distribute however you choose.import requests
campaign_id = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456"
resp = requests.post(
"https://api.rewardscardafrica.com/v1/reward-cards/generate",
headers={
"X-API-Key": "your_api_key_here",
"Content-Type": "application/json",
},
json={
"quantity": 10,
"country": "NG",
"amount": 500,
"campaignId": campaign_id, # required
"categoryIds": ["9f1c2e3a-1234-4a5b-8c9d-abcdef123456"], # optional
},
)
resp.raise_for_status()
codes = resp.json()["codes"]Example response
{
"message": "10 codes generated.",
"codes": [
{
"rewardCardId": "rc_aaa111",
"cardNumber": "RWDNG-8847-2291-XKQP",
"serialNumber": "8847229 1XKQP",
"amount": "500.00",
"currency": "NGN",
"expiredAt": "2029-09-08"
}
]
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| rewardCardId | string (UUID) | No | Internal identifier for this code. |
| cardNumber | string | No | The redemption code itself. Not emailed to anyone — yours to distribute however you choose. |
| serialNumber | string | null | Yes | Non-secret trace identifier, separate from cardNumber. Safe to print in the clear (e.g. next to a scratch panel on a physical voucher). |
| amount | string (decimal) | No | Value of this code, matching the amount you requested. |
| currency | string (ISO 4217) | No | Currency for this code's country. |
| expiredAt | string (date) | null | Yes | Expiry date. Inherits the campaign's expiryDate if set, otherwise a 3-year default. |
Re-scoping an existing campaign
- Country and category scope resolve per-campaign at redemption time, not per code-batch — there is no per-card scope. A generate call that would change a campaign's country or categories is only allowed while that campaign has no codes issued yet.
- Once a campaign has any codes issued (via this endpoint or by submitting recipients), a further generate call with a different country or categoryIds is rejected with 409 — this is deliberate, not a bug. An in-place scope change would silently reshape what already-issued, possibly already-distributed codes can redeem.
- To use a different scope, create a new campaign rather than trying to change an active one.
Daily spend cap
- Checked as ONE total for the whole batch — amount × quantity — before any code is generated, same as recipient submission.
- If the total would exceed your cap, the entire batch is rejected with 429 and nothing is generated.
/v1/campaigns/:campaignId/redemptions Auth requiredSame shape as GET /v1/reward-card-redemptions, filtered to one campaign — see that endpoint below for the full field reference. Use this when you need redemption activity for a specific campaign rather than your whole account's history.
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/campaigns/9f1c2e3a-1234-4a5b-8c9d-abcdef123456/redemptions" \
-H "X-API-Key: your_api_key_here"const campaignId = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456";
const resp = await fetch(
`https://api.rewardscardafrica.com/v1/campaigns/${campaignId}/redemptions`,
{ headers: { "X-API-Key": "your_api_key_here" } }
);
const { redemptions } = await resp.json();import requests
campaign_id = "9f1c2e3a-1234-4a5b-8c9d-abcdef123456"
resp = requests.get(
f"https://api.rewardscardafrica.com/v1/campaigns/{campaign_id}/redemptions",
headers={"X-API-Key": "your_api_key_here"},
)
resp.raise_for_status()
redemptions = resp.json()["redemptions"]/v1/wallet-balance Auth requiredReturns your current wallet balance. Balance is a computed value — the sum of all completed transactions on your account — not a raw stored field. The value returned is always accurate to the time of the request.
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/wallet-balance" \
-H "X-API-Key: your_api_key_here"const resp = await fetch(
"https://api.rewardscardafrica.com/v1/wallet-balance",
{ headers: { "X-API-Key": "your_api_key_here" } }
);
const { amount, currency } = await resp.json();resp = requests.get(
"https://api.rewardscardafrica.com/v1/wallet-balance",
headers={"X-API-Key": "your_api_key_here"},
)
data = resp.json() # { "amount": 2450.00, "currency": "USD" }Example response
{
"currency": "USD",
"amount": 2450.00
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| currency | string (ISO 4217) | No | ISO 4217 currency code of your wallet, e.g. 'USD'. Your wallet is single-currency per the terms of the partnership agreement. |
| amount | number | No | Current balance as a number (not a string, unlike product amounts). Represents the total available to spend on card issuance. Decreases by the face value of each card issued (in your base wallet currency at the agreed rate). |
Notes
- amount is a number (not a string), unlike product and card amounts which are strings. This is consistent with how the underlying computation returns it.
- Wallet currency is fixed per your partnership agreement. Contact tech@ribirewards.com if you need to top up or change the currency.
- There is no pagination or history endpoint for wallet transactions at this time.
/v1/reward-card-redemptions Auth requiredReturns all reward cards issued via your API key, with their current card status and fulfilment status. Use this to track deliveries, build your own reconciliation, or poll for status updates without implementing a webhook listener.
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/reward-card-redemptions" \
-H "X-API-Key: your_api_key_here"const resp = await fetch(
"https://api.rewardscardafrica.com/v1/reward-card-redemptions",
{ headers: { "X-API-Key": "your_api_key_here" } }
);
const { redemptions } = await resp.json();
const pending = redemptions.filter(r => r.fulfilmentStatus !== "ORDER_DELIVERED");resp = requests.get(
"https://api.rewardscardafrica.com/v1/reward-card-redemptions",
headers={"X-API-Key": "your_api_key_here"},
)
redemptions = resp.json()["redemptions"]
pending = [r for r in redemptions if r["fulfilmentStatus"] != "ORDER_DELIVERED"]Example response
{
"redemptions": [
{
"rewardCardId": "rc_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"cardNumber": "RWDZA-8847-2291-XKQP",
"recipientEmail": "lerisha@example.com",
"amount": "500.00",
"currency": "ZAR",
"status": "ACTIVE",
"orderNumber": "RCA-20260818-001",
"fulfilmentStatus": "ORDER_PLACED",
"issuedAt": "2026-08-18T10:23:00.000Z",
"redeemedAt": null
}
]
}Response fields — redemption record
| Field | Type | Nullable | Description |
|---|---|---|---|
| rewardCardId | string (UUID) | No | Identifier for the issued reward card. Use this to correlate redemption records with the generatedCards entries returned when you submitted recipients. |
| cardNumber | string | No | The redemption code, same as returned at issuance. |
| recipientEmail | string | No | The email address supplied at issuance. |
| amount | string (decimal) | No | Face value of the card at issuance. String — parse before arithmetic. |
| currency | string (ISO 4217) | No | Currency of the card. |
| status | string (enum) | No | Card status — 'PENDING' (issued, not yet claimed by the recipient), 'ACTIVE' (claimed, not yet redeemed or expired), 'REDEEMED', 'EXPIRED', 'BLOCKED'. A freshly-issued card is PENDING until the recipient claims it on the redemption shop. Different from fulfilmentStatus: status reflects the card's own lifecycle; fulfilmentStatus reflects whether the physical or digital product has been delivered. |
| orderNumber | string | Yes | Human-readable order reference. Null if no fulfilment order was created (unusual — should always be present for partner-issued cards). |
| fulfilmentStatus | string (enum) | Yes | Current fulfilment status. Null in the rare case where no order record is associated. See fulfilment status reference for all values and meanings. |
| issuedAt | string (ISO 8601) | No | UTC timestamp of when the card was issued, e.g. '2026-08-18T10:23:00.000Z'. |
| redeemedAt | string (ISO 8601) | Yes | UTC timestamp of when the recipient claimed the card. Null if not yet redeemed. |
Notes
- Results are scoped to your partner account only. You cannot retrieve cards issued by other partners.
- There is no pagination at this time — all records are returned in a single response. Contact us if you need cursor-based pagination.
- status and fulfilmentStatus are distinct. A card can be ACTIVE (not yet redeemed by the recipient) while its fulfilmentStatus is ORDER_DELIVERED (the product has been delivered).
- Use this endpoint to poll for fulfilment updates rather than building a webhook listener — polling once every few minutes is sufficient for most fulfilment scenarios.
/v1/request-api-key No auth requiredRequest API access. This is the one endpoint that does not require authentication — it exists for integrators who do not have a key yet. Submitting this form notifies the RibiRewards team directly. Key provisioning is manual and typically takes one business day.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| businessName | string | Required | Your company or organisation name.Max 200 characters. |
| contactName | string | Required | Name of the primary technical contact.Max 100 characters. |
| contactEmail | string | Required | Email address where credentials will be delivered.Must be a valid email. Normalised before storage. |
| note | string | Optional | Optional — describe your integration, use case, or any specific requirements.Max 1,000 characters. |
Example response
{
"message": "Request received. We'll be in touch at your contact email within 1 business day."
}Notes
- This endpoint is rate-limited to 5 requests per 15 minutes per IP to prevent spam. Every successful submission sends a real notification email.
- If you need faster turnaround, email tech@ribirewards.com directly.
- Key provisioning is manual — submission does not automatically create an account or key.
/v1/dashboard-login No auth requiredFor partners who use the partner dashboard (partner.rewardscardafrica.com) but never integrate with this API directly. Exchanges an email address and a dashboard key — a separate credential from your API key, issued by the RibiRewards team — for your partner details. This endpoint is itself the authentication check, so it does not require an X-API-Key header. A dashboard key cannot be used as an X-API-Key on any other endpoint, and an API key cannot be used here.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| emailAddress | string | Required | The email address your dashboard key was issued to.Must be a valid email. |
| dashboardKey | string | Required | Your dashboard key, as given to you by the RibiRewards team.None beyond presence — matched against the stored key. |
Example request
curl -X POST "https://api.rewardscardafrica.com/v1/dashboard-login" \
-H "Content-Type: application/json" \
-d '{
"emailAddress": "you@yourcompany.com",
"dashboardKey": "your_dashboard_key_here"
}'const response = await fetch(
"https://api.rewardscardafrica.com/v1/dashboard-login",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
emailAddress: "you@yourcompany.com",
dashboardKey: "your_dashboard_key_here",
}),
}
);
if (!response.ok) {
const { message } = await response.json();
throw new Error(message);
}
const { partnerId, partnerName, portalSlug } = await response.json();resp = requests.post(
"https://api.rewardscardafrica.com/v1/dashboard-login",
json={
"emailAddress": "you@yourcompany.com",
"dashboardKey": "your_dashboard_key_here",
},
)
resp.raise_for_status()
data = resp.json() # { "partnerId": "...", "partnerName": "...", "portalSlug": null }Example response
{
"partnerId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"partnerName": "Xoxoday",
"portalSlug": null
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| partnerId | string (UUID) | No | Your partner account identifier. Not the same value as an API key — this is only meaningful within a dashboard-key session. |
| partnerName | string | Yes | Your business/company name as configured by the RibiRewards team. Null if not set. |
| portalSlug | string | Yes | Set only for partners with a dedicated, isolated redemption portal (e.g. an embedded white-label shop). Null for the default shared shop. You do not need to do anything with this value directly — it exists for RibiRewards-side portal routing. |
Notes
- A wrong email, a wrong key, or a key that has been deactivated all return the same 422 and the same message — the response never reveals whether a given email is enrolled.
- This endpoint is rate-limited to 10 requests per 15 minutes per IP — a separate, tighter budget from the general edge limit, since this is a login endpoint. See Rate limits.
- A successful login here does not create a session or token — save the emailAddress and dashboardKey yourself and send both again as headers on every subsequent dashboard call (see the next two endpoints).
/v1/dashboard/wallet-balance Auth requiredThe dashboard-key equivalent of GET /v1/wallet-balance — identical response shape, same underlying computation. Authenticated with X-Dashboard-Email and X-Dashboard-Key headers instead of X-API-Key.
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| X-Dashboard-Email | string | Required | Same email used to log in. |
| X-Dashboard-Key | string | Required | Your dashboard key. |
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/dashboard/wallet-balance" \
-H "X-Dashboard-Email: you@yourcompany.com" \
-H "X-Dashboard-Key: your_dashboard_key_here"const resp = await fetch(
"https://api.rewardscardafrica.com/v1/dashboard/wallet-balance",
{
headers: {
"X-Dashboard-Email": "you@yourcompany.com",
"X-Dashboard-Key": "your_dashboard_key_here",
},
}
);
const { amount, currency } = await resp.json();resp = requests.get(
"https://api.rewardscardafrica.com/v1/dashboard/wallet-balance",
headers={
"X-Dashboard-Email": "you@yourcompany.com",
"X-Dashboard-Key": "your_dashboard_key_here",
},
)
data = resp.json()Example response
{
"currency": "USD",
"amount": 2450.00
}Response fields
| Field | Type | Nullable | Description |
|---|---|---|---|
| currency | string (ISO 4217) | No | ISO 4217 currency code of your wallet, e.g. 'USD'. Your wallet is single-currency per the terms of the partnership agreement. |
| amount | number | No | Current balance as a number (not a string, unlike product amounts). Represents the total available to spend on card issuance. Decreases by the face value of each card issued (in your base wallet currency at the agreed rate). |
Notes
- Missing or invalid X-Dashboard-Email / X-Dashboard-Key headers return 422 — this route never falls through to anonymous access.
/v1/dashboard/reward-card-redemptions Auth requiredThe dashboard-key equivalent of GET /v1/reward-card-redemptions — identical response shape. Authenticated with X-Dashboard-Email and X-Dashboard-Key headers instead of X-API-Key.
Headers
| Field | Type | Required | Description |
|---|---|---|---|
| X-Dashboard-Email | string | Required | Same email used to log in. |
| X-Dashboard-Key | string | Required | Your dashboard key. |
Example request
curl -X GET "https://api.rewardscardafrica.com/v1/dashboard/reward-card-redemptions" \
-H "X-Dashboard-Email: you@yourcompany.com" \
-H "X-Dashboard-Key: your_dashboard_key_here"const resp = await fetch(
"https://api.rewardscardafrica.com/v1/dashboard/reward-card-redemptions",
{
headers: {
"X-Dashboard-Email": "you@yourcompany.com",
"X-Dashboard-Key": "your_dashboard_key_here",
},
}
);
const { redemptions } = await resp.json();resp = requests.get(
"https://api.rewardscardafrica.com/v1/dashboard/reward-card-redemptions",
headers={
"X-Dashboard-Email": "you@yourcompany.com",
"X-Dashboard-Key": "your_dashboard_key_here",
},
)
redemptions = resp.json()["redemptions"]Response fields — redemption record
| Field | Type | Nullable | Description |
|---|---|---|---|
| rewardCardId | string (UUID) | No | Identifier for the issued reward card. Use this to correlate redemption records with the generatedCards entries returned when you submitted recipients. |
| cardNumber | string | No | The redemption code, same as returned at issuance. |
| recipientEmail | string | No | The email address supplied at issuance. |
| amount | string (decimal) | No | Face value of the card at issuance. String — parse before arithmetic. |
| currency | string (ISO 4217) | No | Currency of the card. |
| status | string (enum) | No | Card status — 'PENDING' (issued, not yet claimed by the recipient), 'ACTIVE' (claimed, not yet redeemed or expired), 'REDEEMED', 'EXPIRED', 'BLOCKED'. A freshly-issued card is PENDING until the recipient claims it on the redemption shop. Different from fulfilmentStatus: status reflects the card's own lifecycle; fulfilmentStatus reflects whether the physical or digital product has been delivered. |
| orderNumber | string | Yes | Human-readable order reference. Null if no fulfilment order was created (unusual — should always be present for partner-issued cards). |
| fulfilmentStatus | string (enum) | Yes | Current fulfilment status. Null in the rare case where no order record is associated. See fulfilment status reference for all values and meanings. |
| issuedAt | string (ISO 8601) | No | UTC timestamp of when the card was issued, e.g. '2026-08-18T10:23:00.000Z'. |
| redeemedAt | string (ISO 8601) | Yes | UTC timestamp of when the recipient claimed the card. Null if not yet redeemed. |
Notes
- Results are scoped to your partner account only, same as the API-key version.
- Missing or invalid X-Dashboard-Email / X-Dashboard-Key headers return 422 — this route never falls through to anonymous access.
Response headers
These headers are present on every response.
| Header | Always present | Description |
|---|---|---|
| x-request-id | Yes | A UUID generated per request. Included in server logs — share this with support when reporting an issue to pinpoint the exact log entry. If you send an x-request-id header in your request, that value is used instead of a generated one. |
| content-type | Yes | Always application/json; charset=utf-8. Every response, including errors, is JSON. |
| ratelimit-limit | Yes | The maximum number of requests allowed in the current window for the active rate limit tier. |
| ratelimit-remaining | Yes | How many requests remain in the current window before the limit is hit. |
| ratelimit-reset | Yes | Unix timestamp (seconds) at which the rate limit window resets. |
| retry-after | 429 only | Seconds to wait before retrying. Present only on 429 responses. Always honour this value rather than implementing your own backoff. |
| strict-transport-security | Yes | max-age=31536000; includeSubDomains; preload. HTTPS is required — HTTP requests are not accepted. |
| x-content-type-options | Yes | nosniff. The API never serves HTML or mixed content. |
| x-frame-options | Yes | DENY. |
Errors
All error responses follow the same shape. The message field is always human-readable. Validation errors also include an errors array.
// Standard error
{ "message": "Human-readable description." }
// Validation error (422)
{
"message": "Validation failed.",
"errors": [
{ "type": "field", "msg": "Amount must be a positive number.", "path": "amount", "location": "body" }
]
}Cause: A field value is present but invalid — e.g. country code longer than 2 characters, amount not a positive number, or country does not match any supported country.
{ "message": "Selected country is not supported." }Fix: Check the message field — it will identify the specific field and constraint that failed.
Cause: POST /v1/campaigns/:campaignId/recipients was called without an Idempotency-Key header. This header is required on every recipient-submission call.
{ "message": "Idempotency-Key header is required for this request." }Fix: Add an Idempotency-Key header containing a UUID unique to this attempt. See the Idempotency section.
Cause: The requested path does not exist on this API.
{ "message": "Not found." }Fix: Check the URL path and HTTP method. All endpoints live under /v1/.
Cause: The X-API-Key header was not sent.
{ "message": "Missing X-API-Key header." }Fix: Add X-API-Key: your_key to every request except POST /v1/request-api-key.
Cause: The X-API-Key value does not match any known key, or the key's status is BLOCKED.
{ "message": "Invalid or inactive API key." }Fix: Verify the key value. If the key is correct, contact tech@ribirewards.com to check its status.
Cause: One or more required fields are missing or fail format validation (email format, UUID format, field length, etc.).
{
"message": "Validation failed.",
"errors": [
{ "type": "field", "msg": "Amount must be a positive number.", "path": "amount", "location": "body" }
]
}Fix: Check the errors array — each entry identifies the field, the constraint that failed, and the location (body or query). Fix each flagged field and retry.
Cause: You have exceeded the request rate for the active tier — either the IP-level edge limit or the per-partner limit.
{ "message": "Too many requests for this account. Please slow down and try again shortly." }Fix: Wait for the number of seconds in the Retry-After response header before retrying. Do not poll without back-off.
Cause: Your account has reached the daily spend ceiling for one or more currencies. The cap resets at midnight UTC.
{ "message": "Daily spend cap reached for this account. Contact RibiRewards to raise your limit or try again tomorrow." }Fix: Contact tech@ribirewards.com to raise the cap. The cap resets automatically at midnight UTC.
Cause: A downstream call to the fulfilment service took longer than 15 seconds. The card may or may not have been issued.
{ "message": "Request timed out." }Fix: Retry with the same Idempotency-Key. If the card was issued on the first attempt, you will receive the original result; if not, a new card is issued. Never retry a timed-out redeem without an Idempotency-Key.
Cause: An unexpected error occurred. The message is intentionally generic to avoid leaking internal state.
{ "message": "Something went wrong. Please try again later." }Fix: Retry after a short delay. If the error persists, contact tech@ribirewards.com with the x-request-id from the response header.
Status reference
Fulfilment status
Reflects whether the physical or digital product has been delivered. Returned as fulfilmentStatus on each entry from GET /v1/reward-card-redemptions or GET /v1/campaigns/:campaignId/redemptions.
| Value | Meaning | Terminal? | Auto-advances? |
|---|---|---|---|
| ORDER_PLACED | Order received, pending fulfilment by the RibiRewards team. | No | No — updated by fulfilment team |
| PROCESSING | Being prepared for dispatch or activation. | No | No — updated by fulfilment team |
| SHIPPED | Physically dispatched (relevant for shop-and-ship products). | No | No — updated by fulfilment team |
| ORDER_DELIVERED | Fully delivered and available to the recipient. For instant/voucher products this is set immediately at issuance. | Yes | Yes — instant/voucher products only |
| CANCELLED | Cancelled — the card is no longer valid. | Yes | No — updated by fulfilment team |
| EXPIRED | The card's expiry date passed before redemption. | Yes | No — updated by fulfilment team |
Card status
Reflects the lifecycle of the card itself — distinct from fulfilment status. Returned as status in the redemptions list.
| Value | Meaning |
|---|---|
| PENDING | Card has just been issued; the recipient has not yet logged into the redemption shop with it. |
| ACTIVE | Card is valid and has not been redeemed or expired. |
| REDEEMED | The recipient has claimed the card on the redemption shop. |
| EXPIRED | The card's expiry date has passed. |
| BLOCKED | The card has been administratively blocked. |
Supported countries
Country coverage expands without notice. Adding a new country requires a backend whitelist update — it is not solely a catalogue upload. Re-confirm the live list by calling the catalogue endpoint rather than hardcoding this table.
| Country | Code | Currency code | Currency name |
|---|---|---|---|
| Nigeria | NG | NGN | Nigerian Naira |
| Ghana | GH | GHS | Ghanaian Cedi |
| Egypt | EG | EGP | Egyptian Pound |
| Morocco | MA | MAD | Moroccan Dirham |
| South Africa | ZA | ZAR | South African Rand |
| Kenya | KE | KES | Kenyan Shilling |
| Ethiopia | ET | ETB | Ethiopian Birr |
| Senegal | SN | XOF | West African CFA Franc |
| Algeria | DZ | DZD | Algerian Dinar |
| Botswana | BW | BWP | Botswana Pula |
| Zambia | ZM | ZMW | Zambian Kwacha |
| Ivory Coast | CI | XOF | West African CFA Franc |
| Zimbabwe | ZW | ZWL | Zimbabwean Dollar |
| Rwanda | RW | RWF | Rwandan Franc |
| Tanzania | TZ | TZS | Tanzanian Shilling |
| Uganda | UG | UGX | Ugandan Shilling |
| Tunisia | TN | TND | Tunisian Dinar |
| Cameroon | CM | XAF | Central African CFA Franc |
| Angola | AO | AOA | Angolan Kwanza |
| Mozambique | MZ | MZN | Mozambican Metical |
| Namibia | NA | NAD | Namibian Dollar |
| Madagascar | MG | MGA | Malagasy Ariary |
| Mauritius | MU | MUR | Mauritian Rupee |
| Togo | TG | XOF | West African CFA Franc |
| Benin | BJ | XOF | West African CFA Franc |
| Niger | NE | XOF | West African CFA Franc |
| Burkina Faso | BF | XOF | West African CFA Franc |
| Mali | ML | XOF | West African CFA Franc |
| Congo (DRC) | CD | CDF | Congolese Franc |
To request a specific market, email tech@ribirewards.com.
Glossary
- campaignId
- UUID of a campaign you created via POST /v1/campaigns. Every code issued through POST /v1/campaigns/:campaignId/recipients belongs to one campaign, which carries the country, amount, and category scope shared by every recipient in it.
- rewardCardId / cardNumber
- cardNumber is the actual redemption code — what a recipient uses to claim their reward. Returned per-recipient in generatedCards after a submission, and appears in redemption records.
- categoryIds
- Category UUIDs from GET /v1/categories, passed when creating a campaign. Scopes every recipient's shop to just those categories. Omit for the full catalogue.
- fulfilment status
- Whether the physical or digital product has been delivered (ORDER_PLACED, PROCESSING, SHIPPED, ORDER_DELIVERED, CANCELLED, EXPIRED). Distinct from card status.
- card status
- The lifecycle state of the card itself (PENDING, ACTIVE, REDEEMED, EXPIRED, BLOCKED). A card starts PENDING at issuance and becomes ACTIVE once the recipient claims it on the redemption shop. Distinct from fulfilment status.
- idempotency key
- A UUID you generate and include in the Idempotency-Key request header on POST /v1/campaigns/:campaignId/recipients. Guarantees that retrying a timed-out request does not resend the same batch of codes. Scoped per partner, expires after 24 hours, Redis-backed.
- markupPct
- A per-partner markup percentage configured by the RibiRewards team. If set, product amounts in the catalogue are already marked up in the API response — you do not apply markup yourself.
- daily spend cap
- A per-currency daily ceiling on total redemption value per partner. Checked as one total (campaign amount × recipient count) before a submission is processed. Resets at midnight UTC. Returns 429 when reached. Contact tech@ribirewards.com to raise it.
- dashboard key
- A separate login credential from your API key, for partners who use the partner dashboard (partner.rewardscardafrica.com) but never call the API directly. Issued by the RibiRewards team. Cannot be used as an X-API-Key — it only works with POST /v1/dashboard-login and the /v1/dashboard/* endpoints, including the dashboard-mounted versions of every campaign endpoint.
- portalSlug
- Returned by POST /v1/dashboard-login. Set only for partners with a dedicated, isolated redemption portal. Used internally for portal routing — most integrators can ignore this field.
Changelog
- New: POST /v1/reward-cards/generate — generate a batch of codes with no named recipient yet, for you to distribute yourself. No email is sent. This was previously undocumented here despite being live since 4 Sep 2026 — added now along with the behaviour changes below.
- BREAKING: campaignId is now required on POST /v1/reward-cards/generate. Every code, generated or recipient-submitted, now belongs to a campaign — there is no standalone/unscoped issuance path.
- New: optional categoryIds on POST /v1/reward-cards/generate, re-picked on every call rather than inherited from the campaign's original scope.
- New: once a campaign has any codes issued (via either endpoint), a further generate call that would change its country or category scope is rejected with 409 rather than silently applied — country/category scope resolves per-campaign, not per-batch, so an in-place change would retroactively affect codes already issued under it. Start a new campaign for a different scope.
- New: campaigns now carry a source field ('API' or 'DASHBOARD') recording which auth path created them. Null for campaigns created before this field existed.
- BREAKING: POST /v1/reward-card-redeem is retired. Category selection and one amount replace choosing a specific productId per recipient — nobody should have to browse a country's full product catalogue via an API call just to send one card.
- New: GET /v1/categories — returns just a country's category list (id, title, image), not the full catalogue's nested product arrays. Use this to build a category picker.
- New: POST /v1/campaigns — create a named campaign with a country, an amount, and an optional category scope. Everything issued under it shares these settings.
- New: POST /v1/campaigns/:campaignId/recipients — submit up to 1000 recipients (name + email only) against a campaign in one call. Each gets emailed their code directly. Requires an Idempotency-Key, same protection the old redeem endpoint had. Per-row failures don't fail the whole submission.
- New: GET /v1/campaigns/:campaignId/redemptions — redemption activity scoped to one campaign, same shape as the account-wide version.
- New: recipient emails are now branded with your business name when available ("Acme sends you a reward card") instead of generic RibiRewards copy.
- Fixed: daily spend cap documentation previously implied per-recipient checking — corrected to describe the real behaviour, which checks the whole submission's total before anything is generated.
- Corrected: idempotency was previously documented as in-memory-only, needing Redis before multi-instance scaling. It is Redis-backed as of this release — corrected rather than left stale.
- New: optional campaignId field on POST /v1/reward-card-redeem. Reference a campaign (created by RibiRewards) to automatically scope the recipient's shop to specific categories, apply a campaign logo, and — if the campaign has a partner attached — restrict redemption to that partner's own portal. Omitting it behaves exactly as before.
- New: dashboard-only login for partners who use the partner dashboard but never integrate with the API directly. POST /v1/dashboard-login exchanges an email + dashboard key (a separate credential from your API key, issued by RibiRewards) for your partner details. GET /v1/dashboard/wallet-balance and GET /v1/dashboard/reward-card-redemptions accept the same credential via X-Dashboard-Email / X-Dashboard-Key headers and return identical response shapes to their API-key equivalents. Has its own rate limit — see Rate limits.
- Fixed: deduplication (same emailAddress + productId with an ACTIVE card) now actually returns the existing card instead of issuing a duplicate.
- Fixed: expiryDate is now echoed back correctly — a UTC/local-time parsing bug could shift it a day earlier than the expiry you sent.
- Fixed: wallet balance now decreases by a card's face value (converted to your wallet currency) at issuance — previously the debit was never recorded.
- Fixed: giftLink now points to a working redemption page (shop.rewardscardafrica.com/redeem?code=...) instead of a dead link.
- Fixed: catalogue amount now always renders with two decimal places (e.g. '500.00'), matching the redeem response instead of sometimes dropping them (e.g. '500').
- Documented: card status can be PENDING (issued, not yet claimed by the recipient) in addition to ACTIVE/REDEEMED/EXPIRED/BLOCKED — this value already existed but was missing from the docs.
- Initial public release. Five endpoints: /v1/reward-card-catalogue, /v1/reward-card-redeem, /v1/wallet-balance, /v1/reward-card-redemptions, /v1/request-api-key.
- Per-partner catalogue markup (markupPct) applied server-side — partners receive pre-marked-up prices.
- Currency-aware daily spend cap — each currency tracked independently per partner.
- Idempotency support on POST /v1/reward-card-redeem with 24-hour key TTL.
- Three-tier rate limiting: IP edge limiter, per-partner limiter, stricter per-partner redeem limiter.
- Structured request logging with per-request x-request-id. Auth headers and request bodies never logged.
- Fulfilment order row created for every partner redemption — visible in admin with PARTNER: tag.
Ready to integrate?
Request API access and we'll have credentials and a sandbox endpoint to you within one business day.