Developer guide

Integrate SaveApp cards

Everything you need to issue UnionPay cards from your own systems: credentials, access tokens, the card endpoints, webhooks and going live. Prefer to explore? Open the API reference.

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.

  1. Name it after the system that will use it, for example "Production server".
  2. Choose scopes. Cards lets the key use every card endpoint. Wallet as a service is coming later.
  3. 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_…"}'

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.

ConventionDetails
Base URLhttp://saveapp.inibyte.co/api
HeadersAuthorization: Bearer <access_token> on every call except the token call; Content-Type: application/json on calls with a body.
Card idsCards are addressed by their reference (the UnionPay card alias), e.g. /card/41000002.
AmountsKenya shillings (KES) as numbers in shillings, e.g. 1250.5.
DatesISO 8601 in UTC, e.g. 2026-11-06T08:38:23.904Z. Date filters use YYYY-MM-DD.
Mobile numbers0712345678, 254712345678 or +254712345678; SaveApp stores 2547XXXXXXXX.
Paginationpage (from 1) and pageSize (default 10, max 50). Lists answer { page, pageSize, count, data } where count is the total.
Unknown fieldsIgnored. 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.

POST/auth/tokenGet an access token

Exchanges a credential for a token that lasts 60 minutes. No Authorization header. Must come from an IP on the credential's whitelist.

Body

FieldTypeRequiredNotes
client_idstringYesStarts with sa_
client_secretstringYesStarts with sas_

Response 200

access_tokenstringSend as Authorization: Bearer …
token_typestringAlways Bearer
expires_innumberSeconds until it expires (3600)
scopesstring[]What the credential can do, e.g. ["Card"]

Errors

  • 400 client_id or client_secret missing
  • 401 Invalid client credentials, or the credential is switched off
  • 403 IP not on the whitelist, business not approved, no whitelist in production, or five wrong secrets in 15 minutes
GET/cardsList cards

Your cards, newest first.

Query parameters

NameTypeNotes
pageintegerDefault 1
pageSizeintegerDefault 10, max 50
statusstringActive, Blocked, Terminated or Replaced
mobilestringOnly 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.

POST/cardsIssue a card

Registers 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

FieldTypeRequiredNotes
mobilestringYesCardholder's Kenyan mobile number
namestringYesName on the card, up to 80 characters
descriptionstringNoYour 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

  • 400 Invalid mobile or name, or UnionPay refused to register the cardholder or create the card (its message is passed on)
  • 502 UnionPay could not be reached; nothing was created, so you can retry
GET/card/:referenceGet a card

The card as SaveApp stores it (no call to UnionPay, so it's fast).

Path parameters

referencestringThe card's reference

Response 200

A card object. 404 when the card isn't yours or doesn't exist.

GET/card/:reference/detailsLive details

Asks 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"
}
POST/card/:reference/detailsFull details, step 1: send the code

Starts 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

  • 400 The card is terminated or replaced
  • 409 No active webhook endpoint to send the code to
  • 429 More than 20 requests in 15 minutes for this credential
POST/card/:reference/details/verifyFull details, step 2: get them

Send 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

FieldTypeRequiredNotes
challengeIdstringYesFrom step 1
otpstringYesThe 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

  • 400 Wrong code (5 tries, then request a new one), expired, already used or replaced, or unknown challenge
  • 502 UnionPay 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.

GET/card/:reference/balanceLive balance

Response 200

{ "reference": "41000002", "balance": 1250.5, "currency": "KES" }
GET/card/:reference/transactionsLive transactions

Query parameters

NameTypeNotes
fromDatestringYYYY-MM-DD
toDatestringYYYY-MM-DD
countinteger1 to 100
pageintegerFrom 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.

POST/card/:reference/statusBlock, unblock or terminate

Body

FieldTypeRequiredNotes
actionstringYesblock (temporary), unblock or terminate (permanent)
reasonstringYesUp 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

  • 400 The card is already terminated or replaced, or UnionPay refused the change
POST/card/:reference/replaceReplace a card

Issues a new card to the same cardholder. The old card becomes Replaced and stops working.

Body

FieldTypeRequiredNotes
reasonstringYesUp to 200 characters

Response 201

The new card object, with a new reference. Update your records.

The card object

FieldTypeDescription
referencestringThe card's id for every card call
namestringName on the card
descriptionstringYour note (may be empty)
maskedNumberstringFirst six and last four digits. The full number and CVV are only returned by the full details check
typestringUnionPay
currencystringKES
statusstringActive, Blocked, Terminated or Replaced
expiryDatestring or nullEnd of the expiry month, when known
issuedAtstringWhen the card was issued
cardholder.aliasstringUnionPay's cardholder reference
cardholder.mobilestringCardholder mobile, 2547XXXXXXXX
createdAtstringWhen 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.

EventWhen
card.transactionA card was used: amount, currency, merchant and whether it was approved.
card.otpYour 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_otpYou asked for a card's full details. Send the code back to /card/:reference/details/verify.
webhook.testYou 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

cardReferenceThe card's reference
cardAliasSame as cardReference (UnionPay's name for it)
callReferenceUnionPay's id for this event; use it when you contact support
merchant.name, .mcc, .localDateTimeWhere and when the card was used (name usually includes the town and country; mcc is the merchant category code)
transactionDetails.transactionAmount, .transactionCurrencyCodeAmount and currency at the merchant
transactionDetails.settlementAmount, .settlementCurrencyCode, .settlementConversionRateWhat the card was charged when the merchant used another currency; null otherwise
transactionDetails.maskedPanMasked 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" }
  }
}
cardReferenceThe card being used
cardholderAliasUnionPay's cardholder reference
mobileCardholder mobile, 2547XXXXXXXX
otpThe code that authorises the payment. Pass it to your customer at once; don't log or store it.
callReferenceUnionPay's id for this event, or null
merchant.name, .mcc, .countryWho is charging the card (country is ISO 3166-1 alpha-2). merchant is null when UnionPay doesn't say.
transactionDetails.transactionAmount, .transactionCurrencyCodeThe 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

cardReferenceThe card you asked about
challengeIdMatches the challengeId from step 1
otpThe 6-digit code. Use it once, then discard it.
expiresAtWhen 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-EventThe event name
X-SaveApp-DeliveryThe event id
X-SaveApp-TimestampUnix seconds when it was sent
X-SaveApp-Signaturesha256= + 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
});

Errors

Errors come back as JSON with a message you can show or log.

StatusMeaning
400The request is invalid, or UnionPay refused it (its message is passed on).
401Missing, invalid or expired token; wrong client secret; credential switched off.
403IP not on the whitelist, business not approved, credential lacks the scope, or too many failed token attempts.
404No card with that reference in your business.
502UnionPay 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].