Rail Sandbox GatewaySandbox

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

RailBase pathStatus
Card/api/v1/cardAVAILABLE
Carrier billing (DCB)/api/v1/telecomAVAILABLE

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:

WhatValue
Base URLSent to you privately
API keySent to you privately

Keep both as configuration

When the Client's real rail arrives, switching to it should mean changing these two values and nothing else. If either is hardcoded, that defeats the purpose of this gateway existing.

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. 29900 is ৳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

The status code is a convenience; 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.
OutcomeHTTPMeaningSuggested handling
SUCCESS201Charge went throughMark paid
RETRY402Soft decline; retrying later may workDunning / retry schedule
HARD_FAIL402Permanent decline; retrying won't helpCancel, don't retry
none504The rail didn't answer. Empty body.Circuit breaker — a transport failure, not a decline
PENDING202Awaiting subscriber confirmationCarrier billing — nothing is charged yet
EXPIRED200Grace period ran out unconfirmedCarrier billing — its own end state, not a decline

Card endpoints

POST/api/v1/card/tokens

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.

POST/api/v1/card/chargesIDEMPOTENCY-KEY REQUIRED

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

Empty body, zero bytes. No charge record is created, so a later GET won't find one. Only 4000000000000259 does this. Don't try to parse the body.
GET/api/v1/card/charges/{id}

The same shape the create call returns. Unknown id returns 404.

POST/api/v1/card/refunds

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 state
POST/api/v1/telecom/chargesIDEMPOTENCY-KEY REQUIRED

Request

{ "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.

POST/api/v1/telecom/confirm/{confirmationId}

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
}
GET/api/v1/telecom/charges/{id}

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

StatusFinal?Meaning
pending_confirmationnoWaiting on the subscriber. Expires at grace_deadline.
pending_retrynoConfirmed, but the carrier deferred it. next_retry_at says when the next attempt is due, and the confirmation stays usable.
succeededyesCharged.
failedyesHard declined.
expiredyesNobody 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

CodeOutcomeMeaning
insufficient_balanceRETRYThe subscriber's prepaid balance is too low.
carrier_retry_scheduledRETRYConfirmed but deferred by the carrier; awaiting the next attempt.
subscriber_barredHARD_FAILThe 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.

Health, reference, reset

GET/healthNO KEY
{ "status": "ok" }

Or 503 with { "status": "unhealthy", "dependency": "supabase", "error": "…" } when the database is unreachable.

GET/api/v1/referenceNO KEY

The test-value tables for both rails, served straight from the outcome engine — the same source as the tables below. Useful for confirming which environment you're pointed at.

POST/api/v1/resetDESTRUCTIVE
{ "cleared": { "card_tokens": 12, "card_charges": 8, "telecom_charges": 4, "refunds": 2 } }

Clears everything, both rails

Fine between test-suite runs; never from a production code path, and not during a shared demo. If your suite calls this, it also wipes anyone else pointed at the same instance. No real rail exposes anything like it — that's fine, this isn't a real rail.

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 numberOutcomeDecline codeUse for
4242424242424242SUCCESSHappy path
4000000000009995RETRYinsufficient_fundsSoft decline / dunning retry logic
4000000000000341RETRYdo_not_honorSoft decline / dunning retry logic
4000000000000101RETRYissuer_timeoutSoft decline / dunning retry logic
4000000000000002HARD_FAILcard_declined_genericImmediate cancellation logic
4000000000000069HARD_FAILexpired_cardImmediate cancellation logic
4000000000000127HARD_FAILincorrect_cvcImmediate cancellation logic
4000000000000119HARD_FAILprocessing_errorImmediate cancellation logic
4100000000000019HARD_FAILstolen_cardImmediate cancellation logic
4000000000000259TIMEOUTCircuit breaker / timeout handling — the route answers 504 with an empty body

Automated tests must pin a card from this table

Any other card number gets a weighted random outcome — roughly 80% 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

MSISDNConfirmationThenUse for
+8801700000001Confirms immediately when the confirm endpoint is calledSUCCESSHappy path
+8801700000002Confirms immediatelyRETRYRetry/dunning logic — soft decline, same-day retry then next-day shift
+8801700000003Confirms immediatelyHARD_FAILImmediate cancellation logic
+8801700000004Never confirms — stays pending_confirmation until the simulated grace period elapses, then expiresEXPIREDPENDING timeout / grace-period expiry logic
+8801700000005Confirms, but only after a simulated same-day retry then next-day shift on the first attemptSUCCESSRetry-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" } }
CodeHTTPWhen
unauthorized401Missing or wrong API key
invalid_request400Missing or malformed field, missing idempotency key
not_found404Unknown token, charge or confirmation id
rate_limited429Rate limit exceeded; carries Retry-After
internal_error500Gateway 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

  1. Branch on outcome, not HTTP status. 402 is a normal decline, not a failure.
  2. Send a fresh Idempotency-Key per charge attempt, and reuse it on retries of that same attempt.
  3. Handle 504 with an empty body as its own case, distinct from a decline. Nothing reached the rail's ledger and no charge record exists.
  4. A 202 is not a result. Nothing is charged until the confirmation step completes, so don't mark a subscription active off the initiate call.
  5. pending_retry is not terminal. resolved_at is null and the confirmation stays usable.
  6. Don't treat /api/v1/reset as part of the rail contract. It has no analogue on a real rail.
  7. 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

Check 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.