Developers
SureCashBack API
Credit and spend cashback, sync your catalogue and request payments — from your point of sale, app or back office. Fourteen endpoints, one key, signed webhooks.
Base URL
Every request goes to the same host in both environments — your key's prefix, not the URL, is what selects Test or Live.
https://surecashback-mono.up.railway.appsend an sk_t_ keyhttps://surecashback-mono.up.railway.appsend an sk_l_ keyOne host for both environments — the key is the switch
sk_t_ / sk_l_ prefix on your key selects the environment, and /v1/public/ping echoes back which one you are in so you can confirm before anything financial.Quickstart
Four steps from nothing to a customer earning cashback.
- Create a key on API Keys. It is shown once — there is no endpoint that returns it again.
- Validate the key with
/v1/public/ping. It echoes back the environment the key acts in, so you confirm you are pointed at Live or Test before anything financial. - Prove the key with
/v1/public/mebefore any write, so you know which merchant it acts as. - Credit a customer with
/v1/public/cashback/earn.
curl https://surecashback-mono.up.railway.app/v1/public/me \
-H "X-API-Key: sk_l_your_key_here"Authentication
One header on every call. There is no unauthenticated endpoint.
X-API-Key: sk_l_xxxxxxxxxxxxxxxxAuthorization: Bearer sk_… also works. The bearer path is only taken when the token starts with sk_, so it cannot collide with a customer session token.
The key is shown once, at creation
It is stored hashed and there is no endpoint that returns it. Rotation issues a new key and invalidates the old one, so rotate deliberately — an in-flight integration using the old key stops at the moment of rotation.
Live vs Test
The prefix on your key decides which world you are acting in. Nothing else does.
| Prefix | Environment | Acts on | Rate limit |
|---|---|---|---|
sk_l_… | Live | Every endpoint on this page. Real balances | 600 / min |
sk_t_… | Test | /v1/public/ping and /v1/public/me — nothing else | 120 / min |
A test key cannot transact yet
A sk_t_ key reaches exactly two endpoints — /v1/public/ping and /v1/public/me. Every other endpoint on this page, earn and redeem included, refuses it with 403 ENVIRONMENT_NOT_PERMITTED.
This is not a problem with your key and retrying will not clear it. The sandbox partition is unfinished, so a test key is refused on anything that would write a record, notify a real customer, or return live rows.
So build against a live key. Use your own staff account as the test customer and small amounts — an earn can be reversed by an administrator, and every write is idempotent on your reference, so a retried call cannot double-charge while you are finding your feet.
Keys, webhook URLs and webhook secrets are all partitioned, and that partition is real — a Test webhook never receives Live activity. But because a test key cannot transact, it currently produces no deliveries to receive. Configure the Test webhook for the day the sandbox opens; verify your signature handling against Live.
Amounts
All money is kobo — integer minor units. There is no decimal money field anywhere in this API, in either direction.
| You mean | You send |
|---|---|
| ₦1.00 | 100 |
| ₦1,000.00 | 100000 |
| ₦12,345.67 | 1234567 |
Idempotency
Your reference is the idempotency key. It is not optional and it is not decorative.
Send a stable reference derived from your own transaction — a POS receipt id, an order number. Repeating it replays the original outcome.
On a repeat you get
- The same reward — not a second one
replayed: true- No second webhook
Why no second webhook
A replay that fired a delivery would double-notify on every retry you make — and retries are exactly when replays happen.
Derive it from state, never from the clock
A reference built from a timestamp is a different key on every retry, which defeats the mechanism precisely when it is needed.
Endpoints
Fourteen calls — five for cashback, five for your catalogue and outlets, two for payment requests, plus identity and liveness.
One integration, every business type
This API is identical for a standard business, a corporate, a franchise, a bank or fintech, and an integration partner. A key resolves to a merchant identitythat carries no business-type field, so no endpoint, parameter or limit on this page varies by what kind of organisation you are. What differs between them is onboarding (which KYB documents you submit), your dashboard menus, and your commercial terms — none of which reach the wire.
Health check
API key/v1/public/pingValidates your API key and echoes back the environment it acts in. It is the safest call in the API — it reads nothing and writes nothing — which makes it the right first request from any new integration, and the right one to keep in a health check.
Headers
X-API-Key* | sk_l_… / sk_t_… | Required, like every endpoint. ping is one of only two paths a sk_t_ test key may reach |
# Validates the key and echoes its environment. Safe: reads nothing, writes nothing.
curl https://surecashback-mono.up.railway.app/v1/public/ping \
-H "X-API-Key: sk_l_your_key_here"Identify your key
API key/v1/public/meReturns the merchant identity your key resolves to, and whether it is active. Call it once after issuing a key and before any write — it is the only way to confirm a key acts as the merchant you think it does, and it costs nothing to be wrong here rather than on an earn.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
curl https://surecashback-mono.up.railway.app/v1/public/me \
-H "X-API-Key: sk_l_your_key_here"List your outlets
API key/v1/public/me/outletsLists the outlets registered to your merchant, each with the outletId that earn accepts for attribution. Use it to build the branch or store picker your POS sends. This response is paged, so a merchant with more than 25 outlets will not receive them all in one call.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
page | query | integer | 1-based. Defaults to 1. Values below 1 are raised to 1 rather than rejected |
pageSize | query | integer | Defaults to 25, clamped to 1–100. Asking for 500 silently gives you 100 — page, do not assume one call returns everything |
curl https://surecashback-mono.up.railway.app/v1/public/me/outlets \
-H "X-API-Key: sk_l_your_key_here"Read a customer’s rewards
API key/v1/public/customers/{customerId}/rewardsReturns a single customer’s rewards position: what they have earned in total, what is spendable now, what is still pending, and what can be withdrawn. Read this before offering a redemption so you never present an amount the customer cannot actually spend.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
customerId* | path | uuid | Substituted into the URL, not sent as a query string. A non-UUID does not reach the handler — the route itself will not match |
# availableMinor is what can be spent. totalEarnedMinor is NOT a balance.
curl https://surecashback-mono.up.railway.app/v1/public/customers/{customerId}/rewards \
-H "X-API-Key: sk_l_your_key_here"Response
{
"totalEarnedMinor": 4500000,
"availableMinor": 1250000,
"withdrawableMinor": 1250000
}totalEarnedMinor is not a balance
It is lifetime earnings. What can be spent is availableMinor; what can be cashed out is withdrawableMinor. Displaying the first as a balance overstates what the customer has.
Credit cashback (earn)
API key/v1/public/cashback/earnRecords a purchase and credits the resulting cashback to a customer, funded from your own pool. This is the call your POS or checkout makes at the moment of sale. Your API key alone is sufficient here — a deliberate ruling rather than an oversight, because earn only ever credits the customer.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
Content-Type* | application/json | Omitting it returns 415 — this body is never form-encoded |
| Field | In | Type | Notes |
|---|---|---|---|
customerId* | body | uuid | The customer you are crediting |
amountMinor* | body | integer | Purchase value in kobo — not the cashback. We compute the reward from it |
reference* | body | string | Your idempotency key. There is no Idempotency-Key header on this API — replay protection is this field |
channel | body | enum | One of Pos, Qr, App, InStore, Card, Api |
outletId | body | uuid | From /v1/public/me/outlets. An unrecognised outlet does not fail the earn — the reward is written with attribution visibly unresolved |
terminalId | body | string | Your POS terminal identifier. Same forgiving behaviour as outletId |
productReference | body | string | Your product/SKU reference |
curl -X POST https://surecashback-mono.up.railway.app/v1/public/cashback/earn \
-H "X-API-Key: sk_l_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"customerId": "00000000-0000-0000-0000-000000000000",
"amountMinor": 1000000,
"reference": "pos-txn-0001",
"channel": "Pos"
}'Response
{
"reference": "pos-txn-0001",
"rewardId": "01a08282-d8e7-7169-9cdc-94f93040a5ae",
"cashbackMinor": 25000,
"status": "Available",
"availableAt": "2026-09-09T01:00:00Z",
"expiresAt": "2027-09-09T01:00:00Z",
"reasons": [],
"replayed": false
}Spend cashback (redeem)
Key + customer token/v1/public/cashback/redeemApplies a customer’s existing cashback against a purchase at your business. Because this moves the customer’s money rather than crediting it, your API key is deliberately NOT sufficient: the customer must also authorise the specific amount and payee from their own app.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
Content-Type* | application/json | Omitting it returns 415 — this body is never form-encoded |
X-Txn-Authorization* | the customer’s token | Minted by the customer in their own app and bound to this amount and payee. Missing it returns 401 PIN_REQUIRED — your key cannot substitute for it |
| Field | In | Type | Notes |
|---|---|---|---|
customerId* | body | uuid | Must be the same customer the token was minted by |
amountMinor* | body | integer | Kobo to redeem. Cannot exceed the customer’s availableMinor |
reference* | body | string | Your idempotency key. Same field, same rules as earn |
description | body | string | Shown on the customer’s statement |
curl -X POST https://surecashback-mono.up.railway.app/v1/public/cashback/redeem \
-H "X-API-Key: sk_l_your_key_here" \
-H "X-Txn-Authorization: <token the customer minted>" \
-H "Content-Type: application/json" \
-d '{
"customerId": "00000000-0000-0000-0000-000000000000",
"amountMinor": 50000,
"reference": "pos-redeem-0001"
}'Response
{
"reference": "pos-redeem-0001",
"appliedMinor": 50000,
"replayed": false
}The customer mints the token in their own app, bound to operation: "cashback.redeem", the amount and the payee (YOUR-MERCHANT-CODE). You relay it in X-Txn-Authorization. Without it: 401 PIN_REQUIRED.
403 REDEEM_NOT_ENTITLED is not an authorisation failure
It means the customer is not Tier 2, and that check runs before the authorisation check — so you never reached the gate you were testing. Verify their NIN first, then retry.
Look up an earn
API key/v1/public/cashback/{reference}Fetches one earn by the reference you sent, so you can answer “did that land?” without writing anything. Before this existed the only way to check was to re-POST the same reference and read replayed — a write used as a read, and indistinguishable in your logs from a genuine second attempt.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
reference* | path | string | The reference you sent on earn |
# "Did my earn land?" — scoped to your merchant, so a 404 means not yours or not there.
curl https://surecashback-mono.up.railway.app/v1/public/cashback/pos-txn-0001 \
-H "X-API-Key: sk_l_your_key_here"Response
{
"reference": "pos-txn-0001",
"rewardId": "01a08282-d8e7-7169-9cdc-94f93040a5ae",
"customerId": "01a07fcb-f478-7645-9283-97ce508fb208",
"paymentAmountMinor": 250000,
"cashbackMinor": 8750,
"reversedMinor": 0,
"redeemedMinor": 0,
"remainingMinor": 8750,
"status": "Available",
"channel": "Pos",
"appliedRatePct": 3.5,
"earnedAt": "2026-09-09T01:00:00Z",
"expiresAt": "2027-09-09T01:00:00Z"
}remainingMinor is the live figure
cashbackMinor is the historical gross earn and never moves — it is what reconciliation cross-foots against. What is still spendable after any reversal or redemption is remainingMinor.
A customer’s activity with you
API key/v1/public/customers/{customerId}/transactionsLists what a customer has earned at your merchant, newest first. Scoped to you deliberately: a partner who served a customer once does not thereby get their whole SureCashBack history.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
customerId* | path | uuid | The customer to read |
page | query | integer | Defaults to 1 |
pageSize | query | integer | Defaults to 25, clamped to 1–100 |
# This customer's earns AT YOUR MERCHANT only — not their whole history.
curl https://surecashback-mono.up.railway.app/v1/public/customers/{customerId}/transactions?page=1&pageSize=25 \
-H "X-API-Key: sk_l_your_key_here"List your catalogue
API key/v1/public/me/productsYour product catalogue, for syncing a point of sale. Optionally filtered to one category.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
page | query | integer | Defaults to 1 |
pageSize | query | integer | Defaults to 25, clamped to 1–100 |
category | query | string | Exact match. Omit for all categories |
curl https://surecashback-mono.up.railway.app/v1/public/me/products?page=1&pageSize=25 \
-H "X-API-Key: sk_l_your_key_here"Add a product
API key/v1/public/me/productsAdds a product to your catalogue. Safe to call repeatedly: if you send an sku you have used before, the existing product is returned rather than a duplicate created.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
Content-Type* | application/json | Omitting it returns 415 — this body is never form-encoded |
| Field | In | Type | Notes |
|---|---|---|---|
name* | body | string | Product name |
priceMinor* | body | integer | Price in kobo |
category | body | string | Defaults to General when omitted |
sku | body | string | Your own identifier. Sending a repeated sku returns the existing product — this is what makes a catalogue re-sync safe |
unit | body | string | litre, kg, each … |
cashbackRatePct | body | decimal | Product-specific rate, if it differs from your default |
cashbackCapMinor | body | integer | Per-transaction cap in kobo |
# priceMinor is KOBO — 115000 = N1,150.00. Repeating an sku returns the existing product.
curl -X POST https://surecashback-mono.up.railway.app/v1/public/me/products \
-H "X-API-Key: sk_l_your_key_here" \
-H "Content-Type: application/json" \
-d '{"name":"Premium Motor Spirit","sku":"PMS-1L","priceMinor":115000,"category":"Fuel","unit":"litre"}'Read one outlet
API key/v1/public/me/outlets/{id}Fetches a single outlet, including its address and QR payload, without paging the whole list.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
id* | path | uuid | The outlet id |
curl https://surecashback-mono.up.railway.app/v1/public/me/outlets/{outletId} \
-H "X-API-Key: sk_l_your_key_here"Register an outlet
API key/v1/public/me/outletsRegisters a new outlet under your merchant — for chains provisioning branches from their own systems rather than the dashboard.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
Content-Type* | application/json | Omitting it returns 415 — this body is never form-encoded |
| Field | In | Type | Notes |
|---|---|---|---|
name* | body | string | Branch name |
address | body | string | Street address |
city | body | string | |
state | body | string | |
phone | body | string | |
email | body | string |
curl -X POST https://surecashback-mono.up.railway.app/v1/public/me/outlets \
-H "X-API-Key: sk_l_your_key_here" \
-H "Content-Type: application/json" \
-d '{"name":"Ikeja Branch","address":"12 Awolowo Way","city":"Ikeja","state":"Lagos","phone":"+2348012345678"}'Request a payment
API key/v1/public/payments/requestAsks a customer to pay you. You already receive payment_request.paid on your webhook; this is the call that creates the request the event refers to.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
Content-Type* | application/json | Omitting it returns 415 — this body is never form-encoded |
| Field | In | Type | Notes |
|---|---|---|---|
amountMinor* | body | integer | Kobo. Must be positive |
customerPhone | body | string | Phone or email is required — see below |
customerEmail | body | string | Phone or email is required — see below |
customerReference | body | string | Your own reference. Does not satisfy the identifier requirement — it resolves against nothing |
outletId | body | uuid | Must belong to you, or 400 OUTLET_NOT_FOUND |
note | body | string | Shown to the customer |
# amountMinor is KOBO — 250000 = N2,500.00. Phone OR email is required.
curl -X POST https://surecashback-mono.up.railway.app/v1/public/payments/request \
-H "X-API-Key: sk_l_your_key_here" \
-H "Content-Type: application/json" \
-d '{"amountMinor":250000,"customerPhone":"+2348012345678","note":"Table 4"}'Phone or email is required
Without one, returns 400 CUSTOMER_IDENTIFIER_REQUIRED. A request with no reachable customer is not a payment request — it is a row that sits Pending until it expires, with nothing to explain why.
Read a payment request
API key/v1/public/payments/requests/{id}The current state of one payment request — the polling counterpart to the payment_request.paid webhook, for when you would rather ask than wait.
Headers
X-API-Key* | sk_l_… | Your secret key. May instead be sent as Authorization: Bearer sk_l_… |
| Field | In | Type | Notes |
|---|---|---|---|
id* | path | uuid | From the create response |
curl https://surecashback-mono.up.railway.app/v1/public/payments/requests/{id} \
-H "X-API-Key: sk_l_your_key_here"Webhooks
Seven events, signed, delivered to a URL you configure per environment.
Configure a URL and reveal your signing secret on Webhooks. You receive only what you subscribe to — subscribing to * keeps working when a new type is added, which is worth preferring to a list you would have to revisit.
| Event | Fires when |
|---|---|
reward.earned | Cashback awarded — scan, POS, or the earn endpoint. Carries outletId, outletRef, productReference, subUnitId and subUnitRef when the caller stated them |
reward.redeemed | Rewards spent at your business through the public API. Not an in-app redemption or a withdrawal — those have no merchant |
cashback.reversed | An earn reversed or clawed back, only when the reversal names a payment reference. Carries cause — why it happened |
payment_request.paid | A payment request you raised was paid |
settlement.completed | A settlement run paid into your wallet. Carries gross, fee and net — only netMinor actually arrived |
merchant.rating_submitted | A customer rated you. Carries stars and hasComment — never the comment text |
customer.enrolled | A customer joined one of your programmes |
customer.onboarded | You signed a new customer up. Carries customerId only — a customer’s name and contact details are read through your dashboard, not pushed to your server |
partner.staff_changed | Someone joined, changed role or left your team. Carries staffMemberId, change and role; key on the id, not the email |
merchant.updated | Your own terms changed — cashbackRatePct, perTxnCapMinor or isActive. Worth subscribing to even if you ignore the rest: these decide whether a transaction you are about to submit is accepted |
A missing subUnitRef means we were not told — not that the sale was not at a pump. Attribution is recorded only when the caller states it; we never infer it from whichever shift is open, and never correlate by amount and time. Send subUnitRef on the earn endpoint, or have the customer scan that sub-unit's own QR code.
A delivery
POST /your-webhook-endpoint
X-SCB-Event: reward.earned
X-SCB-Delivery: 01a0780c-4f2e-7a91-b3d5-9c1e8f2a6b07
X-SCB-Timestamp: 1788901234
X-SCB-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
Content-Type: application/json
{ "event": "reward.earned", "reference": "pos-txn-0001", ... }The signature covers {timestamp}.{rawBody} — not the bare body
That is what makes a captured delivery non-replayable, and it is the most common mistake made here.
# Recompute the digest by hand. BODY must be the RAW bytes as received —
# if you pretty-print or re-serialise the JSON first, this will not match,
# and that mismatch is the single most common false alarm here.
TS=1788901234 # from X-SCB-Timestamp
SECRET='your_webhook_signing_secret'
BODY="$(cat delivery.json)" # captured verbatim, not reformatted
printf '%s.%s' "$TS" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r \
| awk '{print "sha256=" $1}'
# Compare with the X-SCB-Signature header. Equal => genuine and untampered.Sign the raw bytes
Parsing and re-serialising the JSON changes them and breaks the digest. Capture the body before your framework touches it.
Deduplicate on X-SCB-Delivery
A delivery may arrive more than once. The delivery id is stable across retries; the event body is not a safe key.
Errors
RFC 9457 problem documents carrying a stable, machine-readable code.
Branch on code, never on title
Titles are human-facing copy and may change without notice. code is the contract.
| Code | Status | What to do |
|---|---|---|
UNAUTHENTICATED | 401 | Key missing, unknown or revoked. Check the header name and the prefix |
PIN_REQUIRED | 401 | Redeem without a valid customer token. Have the customer re-authorise |
REDEEM_NOT_ENTITLED | 403 | Customer is not Tier 2 + Active. Not retryable until they verify |
ENVIRONMENT_NOT_PERMITTED | 403 | Almost always a sk_t_ key on an endpoint test keys cannot reach. Not retryable — use a Live key |
— | 429 | Rate limited. Back off and retry with the same reference |
Rate limits
Per key, per minute, applied at the edge.
600/min for a live key, 120/min for a test key. Over the limit returns 429. Retrying a write with the same reference is always safe — that is what idempotency is for.
What does not exist
Listed so you do not build against it.
- No public reversal endpoint — by design, not yet.
cashback.reversedis an event you receive; there is no public route to cause one, and none is planned. Cashback, once granted, cannot be taken back by the merchant that granted it. Reversals originate only from SureCashBack administration — either a refund clawback, or the cancellation of a reward held for review — and a person makes that call. This is a deliberate customer protection: unlikeearn, which only ever credits, a reversal removes value the customer already holds — so it is not something an API key can do. Requesting it will not change the answer; raise a refund through support. - No public customer lookup by email or phone. You must already hold the
customerId. - No usable sandbox. A test key reaches
/pingand/meand nothing else, so integration testing happens against Live. - No bulk earn. One call per transaction — which is also what keeps the idempotency key meaningful.