Overview
The SaveApp merchant API lets an approved business issue prepaid UnionPay cards to its customers and follow what happens to them. Everything is scoped to your business: you only ever see your own cards.
Base URL
http://saveapp.inibyte.co/api
Tokens
Bearer tokens that last 60 minutes
Format
JSON in, JSON out
Before you start, your business must be approved on SaveApp. Sign in to the merchant portal and finish Settings › Onboarding if you haven't yet.
1. Create credentials
In the merchant portal go to Integrations › Credentials and choose New credential.
- Name it after the system that will use it, for example "Production server".
- Choose scopes. Cards lets the key use every card endpoint. Wallet as a service is coming later.
- Add the IP addresses your servers call from, one per line. Only these addresses can get tokens and call the API.
You'll see the client_id and client_secret once. Store them in your secrets manager straight away; SaveApp keeps only a hash and can't show them again. If you lose the secret, rotate it from the credential's panel.
Only people whose role includes "Create and manage API credentials" can create keys. By default that's the business Owner.
2. Get an access token
Exchange the client id and secret for a token. Request a new one when it expires; there's no refresh token.
curl -X POST http://saveapp.inibyte.co/api/auth/token \
-H "Content-Type: application/json" \
-d '{"client_id": "sa_…", "client_secret": "sas_…"}'
const res = await fetch('http://saveapp.inibyte.co/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: process.env.SAVEAPP_CLIENT_ID,
client_secret: process.env.SAVEAPP_CLIENT_SECRET,
}),
});
const { access_token, expires_in } = await res.json();
import os, requests
res = requests.post("http://saveapp.inibyte.co/api/auth/token", json={
"client_id": os.environ["SAVEAPP_CLIENT_ID"],
"client_secret": os.environ["SAVEAPP_CLIENT_SECRET"],
})
token = res.json()["access_token"]
The answer:
{
"access_token": "eyJhbGciOiJIUzI1NiIs…",
"token_type": "Bearer",
"expires_in": 3600,
"scopes": ["Card"]
}
Five wrong secrets for the same client id lock it for 15 minutes. Calls from an address that isn't on the whitelist get 403.
3. Call the API
Send the token in the Authorization header on every call. Each call checks that the token is valid, the credential is still active, your business is approved, the call comes from a whitelisted IP, and the credential has the endpoint's scope. Switching a credential off in the portal blocks its tokens immediately.
| Convention | Details |
|---|---|
| Base URL | http://saveapp.inibyte.co/api |
| Headers | Authorization: Bearer <access_token> on every call except the token call; Content-Type: application/json on calls with a body. |
| Card ids | Cards are addressed by their reference (the UnionPay card alias), e.g. /card/41000002. |
| Amounts | Kenya shillings (KES) as numbers in shillings, e.g. 1250.5. |
| Dates | ISO 8601 in UTC, e.g. 2026-11-06T08:38:23.904Z. Date filters use YYYY-MM-DD. |
| Mobile numbers | 0712345678, 254712345678 or +254712345678; SaveApp stores 2547XXXXXXXX. |
| Pagination | page (from 1) and pageSize (default 10, max 50). Lists answer { page, pageSize, count, data } where count is the total. |
| Unknown fields | Ignored. Missing or invalid fields give 400 with a list of problems in message. |
curl http://saveapp.inibyte.co/api/cards \ -H "Authorization: Bearer $TOKEN"
API reference
Every endpoint, what it expects and what it returns. All card endpoints need a credential with the Cards scope and only ever see your business's cards.
/auth/tokenGet an access tokenExchanges a credential for a token that lasts 60 minutes. No Authorization header. Must come from an IP on the credential's whitelist.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
client_id | string | Yes | Starts with sa_ |
client_secret | string | Yes | Starts with sas_ |
Response 200
access_token | string | Send as Authorization: Bearer … |
token_type | string | Always Bearer |
expires_in | number | Seconds until it expires (3600) |
scopes | string[] | What the credential can do, e.g. ["Card"] |
Errors
400client_idorclient_secretmissing401Invalid client credentials, or the credential is switched off403IP not on the whitelist, business not approved, no whitelist in production, or five wrong secrets in 15 minutes
/cardsList cardsYour cards, newest first.
Query parameters
| Name | Type | Notes |
|---|---|---|
page | integer | Default 1 |
pageSize | integer | Default 10, max 50 |
status | string | Active, Blocked, Terminated or Replaced |
mobile | string | Only the cards of this cardholder |
curl "http://saveapp.inibyte.co/api/cards?status=Active&pageSize=20" \ -H "Authorization: Bearer $TOKEN"
Response 200
{
"page": 1,
"pageSize": 20,
"count": 12,
"data": [ { "reference": "41000002", "name": "Achieng Otieno", "status": "Active", … } ]
}
Each item is a card object.
/cardsIssue a cardRegisters the mobile number as a cardholder with UnionPay the first time it's used, then issues a new card to them. A cardholder can hold several cards.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
mobile | string | Yes | Cardholder's Kenyan mobile number |
name | string | Yes | Name on the card, up to 80 characters |
description | string | No | Your note, up to 300 characters |
curl -X POST http://saveapp.inibyte.co/api/cards \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"mobile": "0712345678", "name": "Jane Wanjiku", "description": "Payout card"}'
Response 201
The new card object.
Errors
400Invalid mobile or name, or UnionPay refused to register the cardholder or create the card (its message is passed on)502UnionPay could not be reached; nothing was created, so you can retry
/card/:referenceGet a cardThe card as SaveApp stores it (no call to UnionPay, so it's fast).
Path parameters
reference | string | The card's reference |
Response 200
A card object. 404 when the card isn't yours or doesn't exist.
/card/:reference/detailsLive detailsAsks UnionPay for the card's current status and expiry. SaveApp also updates its stored status if UnionPay reports a change.
Response 200
{
"reference": "41000002",
"maskedNumber": "623401••••••4321",
"expiry": "12/29",
"status": "Active"
}
/card/:reference/detailsFull details, step 1: send the codeStarts the second check for the card's full number, expiry and CVV. SaveApp sends a 6-digit code to your active webhook endpoints as a card.details_otp event and answers with a challengeId. The code is valid for 5 minutes, and asking again replaces the previous code. You need at least one active webhook endpoint.
curl -X POST http://saveapp.inibyte.co/api/card/41000002/details \ -H "Authorization: Bearer $TOKEN"
Response 202
{
"challengeId": "chl_9f3c0b6e2d1a4c7b8e5f60a1b2c3d4e5",
"expiresAt": "2026-11-06T09:05:00.000Z",
"expiresIn": 300,
"sentTo": 1
}
Errors
400The card is terminated or replaced409No active webhook endpoint to send the code to429More than 20 requests in 15 minutes for this credential
/card/:reference/details/verifyFull details, step 2: get themSend back the challengeId and the code from the webhook. The answer has the full card details straight from UnionPay. SaveApp doesn't store them, and the response is marked Cache-Control: no-store; show them to your customer and don't keep them.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
challengeId | string | Yes | From step 1 |
otp | string | Yes | The 6-digit code from the card.details_otp webhook |
curl -X POST http://saveapp.inibyte.co/api/card/41000002/details/verify \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"challengeId": "chl_9f3c0b6e2d1a4c7b8e5f60a1b2c3d4e5", "otp": "482913"}'
Response 200
{
"reference": "41000002",
"name": "Achieng Otieno",
"cardNumber": "6234010123454321",
"expiry": "12/29",
"cvv": "123",
"status": "Active"
}
Errors
400Wrong code (5 tries, then request a new one), expired, already used or replaced, or unknown challenge502UnionPay could not be reached; request a new code and try again
Full card details are sensitive. Only request them when your customer asks, never log them, and don't keep them after showing them.
/card/:reference/balanceLive balanceResponse 200
{ "reference": "41000002", "balance": 1250.5, "currency": "KES" }
/card/:reference/transactionsLive transactionsQuery parameters
| Name | Type | Notes |
|---|---|---|
fromDate | string | YYYY-MM-DD |
toDate | string | YYYY-MM-DD |
count | integer | 1 to 100 |
page | integer | From 1 |
Response 200
{
"reference": "41000002",
"transactions": [ { … as UnionPay reports them … } ],
"count": 8
}
Transactions are passed through as UnionPay reports them. For a reliable record, store the card.transaction webhooks you receive.
/card/:reference/statusBlock, unblock or terminateBody
| Field | Type | Required | Notes |
|---|---|---|---|
action | string | Yes | block (temporary), unblock or terminate (permanent) |
reason | string | Yes | Up to 200 characters; kept in the card's activity |
curl -X POST http://saveapp.inibyte.co/api/card/41000002/status \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"action": "block", "reason": "Customer reported the phone stolen"}'
Response 200
The updated card object.
Errors
400The card is already terminated or replaced, or UnionPay refused the change
/card/:reference/replaceReplace a cardIssues a new card to the same cardholder. The old card becomes Replaced and stops working.
Body
| Field | Type | Required | Notes |
|---|---|---|---|
reason | string | Yes | Up to 200 characters |
Response 201
The new card object, with a new reference. Update your records.
The card object
| Field | Type | Description |
|---|---|---|
reference | string | The card's id for every card call |
name | string | Name on the card |
description | string | Your note (may be empty) |
maskedNumber | string | First six and last four digits. The full number and CVV are only returned by the full details check |
type | string | UnionPay |
currency | string | KES |
status | string | Active, Blocked, Terminated or Replaced |
expiryDate | string or null | End of the expiry month, when known |
issuedAt | string | When the card was issued |
cardholder.alias | string | UnionPay's cardholder reference |
cardholder.mobile | string | Cardholder mobile, 2547XXXXXXXX |
createdAt | string | When SaveApp stored the card |
{
"reference": "41000002",
"name": "Achieng Otieno",
"description": "Weekly savings payout",
"maskedNumber": "623401••••••4321",
"type": "UnionPay",
"currency": "KES",
"status": "Active",
"expiryDate": "2029-12-31T00:00:00.000Z",
"issuedAt": "2026-10-20T09:12:44.000Z",
"cardholder": { "alias": "41000001", "mobile": "254700000110" },
"createdAt": "2026-10-20T09:12:44.000Z"
}
Webhooks
Add an endpoint under Integrations › Webhooks. SaveApp posts a JSON event to it whenever UnionPay reports something about one of your cards. Reply with any 2xx status within a few seconds; do the heavy work after replying.
| Event | When |
|---|---|
card.transaction | A card was used: amount, currency, merchant and whether it was approved. |
card.otp | Your customer is paying with the card and UnionPay needs a one-time code to authorise it. Pass the code to your customer straight away (SMS or in your app); the payment waits for it. |
card.details_otp | You asked for a card's full details. Send the code back to /card/:reference/details/verify. |
webhook.test | You pressed Send test event in the portal. |
Every event has the same envelope:
{
"id": "evt_0c39011669159a1904722aec",
"event": "card.transaction",
"createdAt": "2026-11-06T08:38:23.904Z",
"data": {
"cardReference": "000000004408",
"cardAlias": "000000004408",
"callReference": "{5D357FCE-FD04-4B4E-909C-9FFB750374C2}",
"merchant": { "name": "NAIVAS WESTLANDS NAIROBI KE", "mcc": 5411, "localDateTime": "2026-11-06T10:00:12Z" },
"transactionDetails": {
"transactionAmount": 250,
"transactionCurrencyCode": "KES",
"settlementAmount": null,
"settlementCurrencyCode": null,
"settlementConversionRate": null,
"maskedPan": "**** **** **** 5958",
"type": { "code": "00", "description": "Authorisation (Goods and services)", "isReversal": false },
"status": { "code": "00", "isSuccessful": true, "reason": "Approved" }
}
}
}
card.transaction data
cardReference | The card's reference |
cardAlias | Same as cardReference (UnionPay's name for it) |
callReference | UnionPay's id for this event; use it when you contact support |
merchant.name, .mcc, .localDateTime | Where and when the card was used (name usually includes the town and country; mcc is the merchant category code) |
transactionDetails.transactionAmount, .transactionCurrencyCode | Amount and currency at the merchant |
transactionDetails.settlementAmount, .settlementCurrencyCode, .settlementConversionRate | What the card was charged when the merchant used another currency; null otherwise |
transactionDetails.maskedPan | Masked card number, last four digits only |
transactionDetails.type | { code, description, isReversal }. code is the ISO 8583 processing code, e.g. 00 Authorisation (goods and services), 01 Cash withdrawal, 09 Goods and services with cash back, 20 Returns (refund), 21 Deposit. When a merchant reverses a payment, description starts with [Reversal] and isReversal is true. |
transactionDetails.status | { code, isSuccessful, reason }. code is the ISO 8583 response code, e.g. 00 Approved, 05 Do not honor, 13 Invalid amount, 41 Lost card, 43 Stolen card, 51 Not sufficient funds. Check isSuccessful rather than the code. |
card.otp data
{
"id": "evt_a88f106e71930d7034057150",
"event": "card.otp",
"createdAt": "2026-11-06T10:00:05.112Z",
"data": {
"cardReference": "000000004408",
"cardholderAlias": "CH000404579",
"mobile": "254794310952",
"otp": "534756",
"callReference": "{0E1C7A52-3B8E-4F0B-9D7A-6C2B9E1F4A10}",
"merchant": { "name": "JUMIA KENYA", "mcc": 5399, "country": "KE" },
"transactionDetails": { "transactionAmount": 250, "transactionCurrencyCode": "KES" }
}
}
cardReference | The card being used |
cardholderAlias | UnionPay's cardholder reference |
mobile | Cardholder mobile, 2547XXXXXXXX |
otp | The code that authorises the payment. Pass it to your customer at once; don't log or store it. |
callReference | UnionPay's id for this event, or null |
merchant.name, .mcc, .country | Who is charging the card (country is ISO 3166-1 alpha-2). merchant is null when UnionPay doesn't say. |
transactionDetails.transactionAmount, .transactionCurrencyCode | The amount being authorised, or null when UnionPay doesn't say |
A good message to your customer: "534756 is your code to pay KES 250 at JUMIA KENYA. Never share it." Only send it to the cardholder's own phone or app.
card.details_otp data
cardReference | The card you asked about |
challengeId | Matches the challengeId from step 1 |
otp | The 6-digit code. Use it once, then discard it. |
expiresAt | When the code stops working (5 minutes) |
Use the event id to ignore duplicates. Deliveries are not retried yet, so watch Integrations › Event logs: it keeps every delivery, with the payload and your endpoint's answer, for a month.
Verify signatures
Every event carries these headers:
X-SaveApp-Event | The event name |
X-SaveApp-Delivery | The event id |
X-SaveApp-Timestamp | Unix seconds when it was sent |
X-SaveApp-Signature | sha256= + HMAC-SHA256 of <timestamp>.<raw body> with the endpoint's signing secret |
Compute the signature over the raw request body, compare in constant time, and reject events older than five minutes.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/saveapp', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-SaveApp-Timestamp');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SAVEAPP_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const received = req.get('X-SaveApp-Signature') ?? '';
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const valid = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid || !fresh) return res.status(400).end();
const event = JSON.parse(req.body);
res.status(200).end(); // reply first, then handle the event
});
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/saveapp")
def saveapp_webhook():
timestamp = request.headers.get("X-SaveApp-Timestamp", "")
body = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(
os.environ["SAVEAPP_WEBHOOK_SECRET"].encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
received = request.headers.get("X-SaveApp-Signature", "")
if not hmac.compare_digest(received, expected) or abs(time.time() - int(timestamp)) > 300:
abort(400)
event = request.get_json()
return "", 200
<?php
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SAVEAPP_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('SAVEAPP_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_SAVEAPP_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(200);
Errors
Errors come back as JSON with a message you can show or log.
| Status | Meaning |
|---|---|
400 | The request is invalid, or UnionPay refused it (its message is passed on). |
401 | Missing, invalid or expired token; wrong client secret; credential switched off. |
403 | IP not on the whitelist, business not approved, credential lacks the scope, or too many failed token attempts. |
404 | No card with that reference in your business. |
502 | UnionPay could not be reached. Try again shortly. |
{ "message": "Requests from 203.0.113.99 are not allowed", "error": "Forbidden", "statusCode": 403 }
Going live checklist
- Your business shows Approved in the merchant portal.
- Each server has its own credential, with only the scopes it needs.
- Every credential has an IP whitelist (required in production).
- Client secrets and webhook signing secrets live in a secrets manager, not in code.
- Your webhook endpoint uses HTTPS, verifies signatures and ignores duplicate event ids.
- Someone checks Event logs for failed deliveries.
Questions? Write to [email protected].