Testing in Postman
This guide drives every StarPay flow end-to-end from Postman: mobile-money collection, mobile-money & bank payout, and name enquiry. Run it against the sandbox first — the Simulator gives deterministic outcomes with no real money or providers.
1. Import the collection
A Postman collection is generated from the live OpenAPI schema, so it always mirrors the current contract:
make postman # writes docs/starpay.postman_collection.json
In Postman: Import → File → docs/starpay.postman_collection.json. Every
endpoint appears with its request body. (You can also import the raw schema from
GET /api/schema/.)
2. Create an environment
Add a Postman Environment with these variables:
| Variable | Example | Notes |
|---|---|---|
base_url |
https://api-sandbox.starpay.app |
No trailing slash, no stage path |
secret_key |
sk_test_… |
Your API secret key (sk_test_ sandbox / sk_live_ prod) |
signing_secret |
whsec_… |
The merchant signing secret (money-out HMAC) — distinct from the API key |
secret_key authenticates every request; signing_secret signs money-out
requests (see step 4).
Find them in the merchant dashboard, under Developers: a secret key is shown once when you create it, and your signing secret is behind Show my signing secret.
🔴 Two different values both look like whsec_…. Your signing secret is
the one you sign requests WITH. Each webhook endpoint has its own secret,
which is what StarPay signs deliveries to that endpoint with. Using the
endpoint's secret to sign a request fails with signature_invalid.
3. Auth & idempotency headers
Set these on the collection (Collection → Authorization / Headers) so every request inherits them:
Authorization: Bearer {{secret_key}}- On money-moving
POSTs, addIdempotency-Key: {{$guid}}— Postman's$guidmints a fresh UUID per send, so replays are safe. Re-sending the same key returns the original response; the same key with a different body returns409 idempotency_conflict.
4. HMAC signing (money-out only)
POST /transfers additionally requires an HMAC signature. The server verifies:
X-Starpay-Signature = HMAC_SHA256(signing_secret, "{ts}.{METHOD}.{path}.{idempotencyKey}.{sha256(body)}")
X-Starpay-Timestamp = {ts} # unix seconds; must be within 300s of server time
{path} is the API path only — e.g. /api/v1/transfers (no host, no stage
prefix). {idempotencyKey} is the same value you send in the Idempotency-Key
header (signing it in stops a captured signature being replayed under a fresh
key). Paste this Pre-request Script on the signed requests (or on a folder
that groups them):
const ts = Math.floor(Date.now() / 1000).toString();
const method = pm.request.method.toUpperCase();
const path = pm.request.url.getPath(); // e.g. /api/v1/transfers
const raw = (pm.request.body && pm.request.body.raw) || "";
const body = pm.variables.replaceIn(raw); // resolve {{vars}} so the hash matches what is sent
// Generate (or reuse) the Idempotency-Key and sign it alongside the body.
const idem = pm.request.headers.get("Idempotency-Key") || (crypto?.randomUUID?.() ?? `${ts}-${Math.random()}`);
const bodyHash = CryptoJS.SHA256(body).toString(); // hex
const message = `${ts}.${method}.${path}.${idem}.${bodyHash}`;
const signature = CryptoJS.HmacSHA256(message, pm.environment.get("signing_secret")).toString();
pm.request.headers.upsert({ key: "Idempotency-Key", value: idem });
pm.request.headers.upsert({ key: "X-Starpay-Timestamp", value: ts });
pm.request.headers.upsert({ key: "X-Starpay-Signature", value: signature });
If you get
401 signature_invalid, it's almost always one of: a body that still contains unresolved{{variables}}(the script above resolves them), abase_urlthat includes a path/stage prefix, or clock skew > 5 minutes.
5. Sandbox magic values
The Simulator resolves outcomes deterministically:
| Input | Outcome |
|---|---|
MoMo 0240000000 |
success |
MoMo 0240000001 |
insufficient funds; no match for name enquiry (404) |
MoMo 0240000002 |
timeout — the rail never answers and the charge fails |
MoMo 0240000004 |
timeout after the request was sent — the charge stays pending and never settles |
Card 4084 0840 8408 4081 |
success |
Card 4084 0840 8408 4082 |
declined |
6. Run the flows
Name enquiry (read-only — start here)
POST {{base_url}}/api/v1/resolve/momo-name → { "msisdn": "0240000000" } →
200 with data.name. Try 0240000001 to see the 404 not_found path.
POST {{base_url}}/api/v1/resolve/bank-name →
{ "account_number": "1234567890", "bank_code": "GCB" }.
Mobile-money collection
POST {{base_url}}/api/v1/charges (Bearer + Idempotency-Key):
{ "amount": 11050, "currency": "GHS", "channel": "momo", "msisdn": "0240000000" }
Returns pending_provider (or success on the Simulator's sync path). Fetch
state with GET /api/v1/charges/{id}.
Bank payout (signed)
POST {{base_url}}/api/v1/transfers (Bearer + Idempotency-Key + HMAC script):
{
"amount": 20000, "currency": "GHS", "channel": "bank",
"account_number": "1234567890", "bank_code": "GCB", "bank_account_name": "Ama Owusu"
}
Swap to a MoMo payout with { "channel": "momo", "msisdn": "0540000000" }.
Insufficient balance returns 422 insufficient_funds — fund the wallet first via
a collection.
7. Callbacks
Collections and payouts finalize asynchronously on a provider callback, so a
pending initiate is expected. The provider posts to the internal webhook
receiver (/api/v1/providers/{provider}/webhook/), which settles the
transaction and fires your merchant
webhook. When testing locally you won't receive real provider
callbacks unless your endpoint is reachable — re-fetch the resource to observe
the final state, or use the Simulator's synchronous outcomes.