Integration reference · for the Multi-Rail Subscription Engine team
API reference
A stand-in for the Client's card and carrier billing rails, so your adapters can be built and tested against something realistic before the real APIs exist — then repointed at the real thing by changing configuration only. No adapter code should be written specifically for this gateway.
What's available
| Rail | Base path | Status |
|---|---|---|
| Card | /api/v1/card | AVAILABLE |
| Carrier billing (DCB) | /api/v1/telecom | AVAILABLE |
Both rails are live. This is a sandbox: it never touches real money, never contacts a real rail, and holds only synthetic test data.
Connecting
Two pieces of configuration, and nothing else:
| What | Value |
|---|---|
| Base URL | Sent to you privately |
| API key | Sent to you privately |
Keep both as configuration
Authentication
Authorization: Bearer <API_KEY>Required on every call except GET /health and GET /api/v1/reference, which are open so you can confirm you're wired up before the key is necessarily right. A wrong or missing key returns 401 — if every call 401s, it's almost always the key value rather than an access problem.
First call to make
curl {BASE_URL}/health
→ {"status":"ok"}This checks the gateway and its database, returning 503 if the database is unreachable. It won't report healthy while broken, so it's safe to trust in a startup self-test.
Conventions
- Money is an integer in the smallest currency unit.
29900is ৳299.00. Never a float, anywhere. - Timestamps are UTC ISO 8601 —
2026-08-09T03:36:02.197213+00:00. - IDs are prefixed:
tok_tokens,ch_charges,re_refunds,conf_confirmations. - Ignore response fields you don't recognise rather than failing on them. Existing field names won't change without being flagged in the gateway's changelog.
Outcomes
Branch on outcome, not HTTP status
outcome is the contract. A 402 is a normal, expected decline — not an error condition. Only 5xx and 504 mean the gateway itself is in trouble.| Outcome | HTTP | Meaning | Suggested handling |
|---|---|---|---|
| SUCCESS | 201 | Charge went through | Mark paid |
| RETRY | 402 | Soft decline; retrying later may work | Dunning / retry schedule |
| HARD_FAIL | 402 | Permanent decline; retrying won't help | Cancel, don't retry |
| none | 504 | The rail didn't answer. Empty body. | Circuit breaker — a transport failure, not a decline |
| PENDING | 202 | Awaiting subscriber confirmation | Carrier billing — nothing is charged yet |
| EXPIRED | 200 | Grace period ran out unconfirmed | Carrier billing — its own end state, not a decline |
Card endpoints
Sandbox tokenization. The full card number is never returned, here or anywhere else.
Request
{ "card_number": "4242424242424242", "exp_month": 12, "exp_year": 2028, "cvc": "123" }Response 201
{
"token": "tok_1846d4f570f149e9be35bfb4",
"last4": "4242",
"brand": "visa",
"exp_month": 12,
"exp_year": 2028
}Any missing or malformed field returns 400. The cvc is required and deliberately not stored.
Charges a token, resolving synchronously — there's no pending state on this rail. Send an Idempotency-Key header, or idempotency_key in the body. Missing returns 400; unknown token returns 404.
Request
{ "token": "tok_1846d4f570f149e9be35bfb4", "amount": 29900, "currency": "BDT" }Response 201 — success
{
"id": "ch_8e09e1266ae147f9aeb8e209",
"amount": 29900,
"currency": "BDT",
"status": "succeeded",
"outcome": "SUCCESS",
"decline_code": null,
"decline_message": null,
"created_at": "2026-08-09T03:36:02.197213+00:00"
}Response 402 — decline
{
"id": "ch_428d5a5443924f2eb3dbceb2",
"amount": 29900,
"currency": "BDT",
"status": "failed",
"outcome": "RETRY",
"decline_code": "insufficient_funds",
"decline_message": "The card has insufficient funds.",
"created_at": "2026-08-09T03:36:04.133616+00:00"
}Response 504 — no answer
GET won't find one. Only 4000000000000259 does this. Don't try to parse the body.The same shape the create call returns. Unknown id returns 404.
amount is optional and defaults to the full remaining refundable amount.
Request
{ "charge_id": "ch_8e09e1266ae147f9aeb8e209", "amount": 29900 }Response 201
{
"id": "re_175a4ab65766419dbc85cfa3",
"charge_id": "ch_8e09e1266ae147f9aeb8e209",
"amount": 29900,
"status": "succeeded",
"created_at": "2026-08-09T03:36:03.155255+00:00"
}Returns 400 if the charge isn't succeeded, or if the amount exceeds what's still refundable — prior refunds are counted.
Idempotency
Reusing an Idempotency-Key returns the original charge, unchanged, with status 200 instead of 201/402. The body is byte-identical to the first response.
POST /api/v1/card/charges Idempotency-Key: abc → 201 ch_8e09…
POST /api/v1/card/charges Idempotency-Key: abc → 200 ch_8e09… (same charge, no second record)The outcome engine is not re-run on a replay. That matters: for a card outside the deterministic set, re-running would roll a fresh random outcome, so the same request could return SUCCESS on one attempt and HARD_FAIL on the next.
Concurrent requests with the same key are safe. A unique database constraint decides the winner and the loser returns the winner's charge, so correctness doesn't depend on winning a lookup race.
Carrier billing (telecom / DCB)
This rail does not resolve on the first call. It's a two-step flow mirroring a real WAP confirmation: initiating a charge bills nobody until the subscriber confirms.
POST /api/v1/telecom/charges → 202 pending_confirmation · returns confirmation_id
POST /api/v1/telecom/confirm/{id} → 200 the subscriber confirms; only now does it resolve
GET /api/v1/telecom/charges/{id} → 200 current stateRequest
{ "msisdn": "+8801700000002", "amount": 29900, "currency": "BDT" }Response 202
{
"id": "ch_251750f9fb4347a6bda60c37",
"msisdn": "+8801700000002",
"amount": 29900,
"currency": "BDT",
"status": "pending_confirmation",
"outcome": "PENDING",
"decline_code": null,
"decline_message": null,
"confirmation_id": "conf_f49fba3d236c41478cd3e38d",
"confirmation_url": "{BASE_URL}/sandbox-wap/conf_f49fba3d236c41478cd3e38d",
"grace_deadline": "2026-08-09T07:14:09.081+00:00",
"next_retry_at": null,
"created_at": "2026-08-09T07:14:05.193800+00:00",
"confirmed_at": null,
"resolved_at": null
}confirmation_url is for display and demos and doesn't resolve to a page. confirmation_id is the field that matters — it's what the next call needs. grace_deadline is when this charge expires if nobody confirms.
No request body. What happens is decided by the MSISDN's row in the test table, not by anything you send. Unknown or already-resolved returns 404.
Response 200 — resolved
{
"id": "ch_3405119500f441dd923cadf7",
"msisdn": "+8801700000001",
"status": "succeeded",
"outcome": "SUCCESS",
"decline_code": null,
"decline_message": null,
"confirmed_at": "2026-08-09T07:14:06.714+00:00",
"resolved_at": "2026-08-09T07:14:06.714+00:00"
}Response 200 — soft decline, deferred
{
"id": "ch_251750f9fb4347a6bda60c37",
"status": "pending_retry",
"outcome": "RETRY",
"decline_code": "insufficient_balance",
"decline_message": "The subscriber's prepaid balance is too low.",
"next_retry_at": "2026-08-09T07:14:07.688+00:00",
"confirmed_at": "2026-08-09T07:14:05.688+00:00",
"resolved_at": null
}Response 200 — expired, never confirmed
{
"id": "ch_8e4ecdacfa7e4793a5d40734",
"msisdn": "+8801700000004",
"status": "expired",
"outcome": "EXPIRED",
"decline_code": null,
"decline_message": null,
"confirmed_at": null,
"resolved_at": "2026-08-09T07:14:13.539+00:00"
}Statuses, and which are final
| Status | Final? | Meaning |
|---|---|---|
| pending_confirmation | no | Waiting on the subscriber. Expires at grace_deadline. |
| pending_retry | no | Confirmed, but the carrier deferred it. next_retry_at says when the next attempt is due, and the confirmation stays usable. |
| succeeded | yes | Charged. |
| failed | yes | Hard declined. |
| expired | yes | Nobody confirmed in time. |
pending_retry is not a resolved charge
resolved_at stays null and a further confirm call is accepted. Only the three final statuses return 404 on a repeat confirm. An adapter that treats any confirm response as final will silently mark soft declines as settled.
And treat EXPIRED as its own end state rather than folding it into HARD_FAIL — the subscriber never refused, they never answered.
The simulated clock
The grace period and retry shift run on an accelerated clock, not real days — one simulated “day” defaults to 30 seconds, so an unconfirmed charge expires about half a minute after it's initiated. Read grace_deadline off the charge rather than assuming the interval, since a gateway configured for fast tests uses a much shorter one. Expiry is evaluated when the charge is read, so a GET after the deadline returns expired — no confirm call needed, and no background job to wait on.
Carrier billing decline codes
| Code | Outcome | Meaning |
|---|---|---|
| insufficient_balance | RETRY | The subscriber's prepaid balance is too low. |
| carrier_retry_scheduled | RETRY | Confirmed but deferred by the carrier; awaiting the next attempt. |
| subscriber_barred | HARD_FAIL | The subscriber is barred from carrier billing. |
These are per-rail. Card's insufficient_funds is deliberately not reused here — it describes an issuer declining, not a prepaid balance running out.
Test values
These always produce the same result, so you can trigger any path on demand. Rendered from the outcome engine's own tables, so this page cannot disagree with what a charge actually does.
Test cards
| Card number | Outcome | Decline code | Use for |
|---|---|---|---|
| 4242424242424242 | SUCCESS | — | Happy path |
| 4000000000009995 | RETRY | insufficient_funds | Soft decline / dunning retry logic |
| 4000000000000341 | RETRY | do_not_honor | Soft decline / dunning retry logic |
| 4000000000000101 | RETRY | issuer_timeout | Soft decline / dunning retry logic |
| 4000000000000002 | HARD_FAIL | card_declined_generic | Immediate cancellation logic |
| 4000000000000069 | HARD_FAIL | expired_card | Immediate cancellation logic |
| 4000000000000127 | HARD_FAIL | incorrect_cvc | Immediate cancellation logic |
| 4000000000000119 | HARD_FAIL | processing_error | Immediate cancellation logic |
| 4100000000000019 | HARD_FAIL | stolen_card | Immediate cancellation logic |
| 4000000000000259 | TIMEOUT | — | Circuit breaker / timeout handling — the route answers 504 with an empty body |
Automated tests must pin a card from this table
SUCCESS, 15% RETRY, 5% HARD_FAIL. That's deliberate, so tests which don't pin a card still see realistic variety. It also means a test using an arbitrary number will be flaky through no fault of your code. The random fallback never produces the 504.Test phone numbers
| MSISDN | Confirmation | Then | Use for |
|---|---|---|---|
| +8801700000001 | Confirms immediately when the confirm endpoint is called | SUCCESS | Happy path |
| +8801700000002 | Confirms immediately | RETRY | Retry/dunning logic — soft decline, same-day retry then next-day shift |
| +8801700000003 | Confirms immediately | HARD_FAIL | Immediate cancellation logic |
| +8801700000004 | Never confirms — stays pending_confirmation until the simulated grace period elapses, then expires | EXPIRED | PENDING timeout / grace-period expiry logic |
| +8801700000005 | Confirms, but only after a simulated same-day retry then next-day shift on the first attempt | SUCCESS | Retry-then-recover logic — succeeds on the second attempt |
Two behaviours that surprise people
+8801700000004 ignores a confirm call — it returns 200 with the charge unchanged and still pending_confirmation, rather than resolving. The number exists to never confirm, so forcing it through would defeat the point. Let it expire.
+8801700000005 needs two confirm calls. The first returns pending_retry; the second succeeds.
Any other number behaves like +8801700000001 — deliberately, so ad hoc testing never lands on the never-confirms case by accident.
Errors
Every error has the same shape:
{ "error": { "code": "snake_case_code", "message": "human readable sentence" } }| Code | HTTP | When |
|---|---|---|
| unauthorized | 401 | Missing or wrong API key |
| invalid_request | 400 | Missing or malformed field, missing idempotency key |
| not_found | 404 | Unknown token, charge or confirmation id |
| rate_limited | 429 | Rate limit exceeded; carries Retry-After |
| internal_error | 500 | Gateway or database problem |
The one exception: 504 has no body at all. Don't try to parse it.
Rate limiting
120 requests per rolling 60 seconds by default, shared across both rails — it models one API credential's budget, not a per-endpoint limit. Exceeding it returns 429 with a Retry-After header.
It exists so your rate-limit handling can be exercised against a real 429 rather than assumed to work. Two caveats: the counter is checked after the API key, so a wrong-key retry loop 401s rather than eating the budget; and it's counted per server instance, so on a multi-instance deployment the effective ceiling is higher than the configured number.
Notes for the adapter
- Branch on
outcome, not HTTP status.402is a normal decline, not a failure. - Send a fresh
Idempotency-Keyper charge attempt, and reuse it on retries of that same attempt. - Handle
504with an empty body as its own case, distinct from a decline. Nothing reached the rail's ledger and no charge record exists. - A
202is not a result. Nothing is charged until the confirmation step completes, so don't mark a subscription active off the initiate call. pending_retryis not terminal.resolved_atis null and the confirmation stays usable.- Don't treat
/api/v1/resetas part of the rail contract. It has no analogue on a real rail. - Nothing here should require adapter code specific to this sandbox. If you find yourself writing a special case for it, that's a gap in the contract worth reporting back rather than working around.
If something looks wrong
GET /health first — a 503 explains most failures at once. After that, the most common cause of a consistent 401 is a wrong key value. If this gateway's behaviour disagrees with this page, that's a bug in the gateway, not something to work around on your side.