Accept a payment
The end-to-end hosted-checkout flow — create an intent, redirect to checkout, react to the confirmed result, and read status and receipts.
A payment is taken through the gateway's hosted checkout — your app never sees card data. You create an intent, send the customer to the returned URL, and react to the confirmed result from a verified webhook.
All amounts are integer minor units (paisa): 150000 means
1,500.00 BDT. The mode (TEST or LIVE) is derived from
the API credential that authenticated the request — never from the request body.
Every response is wrapped in the standard envelope:
{ "data": <payload>, "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "…" }, "pagination": null }.
The examples below show the data payload; see Responses & errors.
The flow
Create the payment
POST /payments with the amount in minor units and your customerReference. Send
an Idempotency-Key so a retry can't double-create —
a repeat with the same key and same body replays the first response verbatim.
curl -X POST http://localhost:8080/api/v1/payments \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" \
-H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e44-7a3b-4c2e-9b21-0c6d5a8e1f44" \
-d '{
"amountMinor": 150000,
"currency": "BDT",
"customerReference": "cust_4821",
"customerEmail": "[email protected]",
"customerName": "Rumana Karim",
"productName": "Pro plan (annual)",
"successUrl": "legacycouch://payment-return",
"metadata": { "orderId": "ord_99812", "cartRef": "cart_5567" }
}'The customerReference find-or-creates a customer for your app — a first-time
reference creates one, a known reference is reused (its contact fields refreshed
from any non-blank customerName / customerEmail / customerPhone you send). See
Customers and Look up customers.
Redirect to checkout
The 201 response is already PENDING (the gateway session opened during create)
and carries a checkoutUrl. Redirect the customer's browser to it; the hosted page
forwards them to the gateway's hosted page (the redirectUrl). In TEST mode this
is the sandbox.
{
"id": 4821,
"reference": "PAY-7K2QF8M3ND",
"status": "PENDING",
"applicationId": 42,
"mode": "TEST",
"amountMinor": 150000,
"currency": "BDT",
"gateway": "SSLCOMMERZ",
"checkoutUrl": "http://localhost:3000/checkout/PAY-7K2QF8M3ND",
"redirectUrl": "https://sandbox.sslcommerz.com/gwprocess/v4/gw.php?Q=pay&SESSIONKEY=AB12CD34",
"metadata": { "orderId": "ord_99812", "cartRef": "cart_5567" },
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:01"
}React to the confirmed result
The gateway calls the service back server-to-server (IPN). The service verifies the
callback, finalizes the payment, posts to the ledger, and
emits a signed payment.succeeded (or payment.failed /
payment.expired) webhook. Treat the webhook as the source of truth.
If you need to check synchronously, read GET /payments/{id} or
GET /payments?customerReference=cust_4821.
Never grant value off the browser redirect alone. The redirect can be lost,
spoofed, or interrupted. Fulfill only on a confirmed SUCCEEDED status — from a
verified webhook or a status read.
Create payment request
POST /api/v1/payments — requires the app API-key headers. The app_id and mode
come from the credential, never from the body.
| Field | Type | Required | Notes |
|---|---|---|---|
amountMinor | integer (int64) | yes | Integer minor units (paisa). Must be > 0 and within the app's configured min/max bounds (PAY-12). A non-integer JSON value fails to bind. |
currency | string | no | Max 3 chars. Defaults to BDT. BDT-only in v1 — any other value is rejected with 400 Unsupported currency. |
customerReference | string | yes | Your own identifier for the customer (not our id). Max 255 chars, non-blank. Find-or-creates the customer record. |
customerEmail | string | no | Valid email, max 320 chars. Pre-fills the gateway page; refreshes the stored customer contact. |
customerPhone | string | no | Max 32 chars. Pre-fills the gateway page; refreshes the stored customer contact. |
customerName | string | no | Max 255 chars. Shown on the gateway page; refreshes the stored customer contact. |
productName | string | no | Max 255 chars. Short description shown on the hosted checkout page. |
invoiceId | integer (int64) | no | Links this payment to an invoice; null for a standalone payment. |
metadata | JSON object | no | Bounded passthrough object (see below). Echoed unchanged on the settlement webhook. |
successUrl | string | no | Max 2048 chars. Browser return URL on success; allow-list validated (see below). Custom schemes (deep links) allowed. |
failUrl | string | no | Max 2048 chars. Browser return URL on a failed attempt; same allow-list rules. |
cancelUrl | string | no | Max 2048 chars. Browser return URL on a cancelled attempt; same allow-list rules. |
Return URLs & deep links
successUrl / failUrl / cancelUrl are where the service-hosted return page hops
the customer's browser after the gateway flow completes — typically a deep link
back into a native app (e.g. legacycouch://payment-return). Custom URL schemes are
allowed on purpose, so there is no generic URL-format constraint.
Each non-null URL is checked against the app's return-URL allow-list —
deny-by-default. An out-of-allow-list value is rejected with 400, so an
attacker-influenceable redirect target can never leave the app's declared prefixes.
- Per-field resolution: the per-payment value wins; if omitted, the app's configured
default for that terminal state is used; if neither is set, the field is
null. - The gateway is always handed the service's own hosted return pages — your URLs only drive the final hop after the result is shown.
# Rejected: successUrl outside the app's allow-list → 400
curl -X POST http://localhost:8080/api/v1/payments \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" -H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f" \
-H "Content-Type: application/json" \
-d '{ "amountMinor": 150000, "customerReference": "cust_4821",
"successUrl": "https://evil.example.com/return" }'{
"data": null,
"meta": {
"success": false,
"message": "successUrl is not permitted by this app's return-URL allow-list",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}Metadata passthrough
metadata is an optional JSON object stored as-is and echoed back unchanged on
the settlement webhook; it is never interpreted or merged. Use it for your own order
ids, cart references, etc. It is bounded (size / depth / key-count, object-root only,
tunable via app.metadata.*).
When metadata is omitted on a payment raised for an invoice (invoiceId set),
the invoice's stored metadata is inherited. When present, your value is used
as-is — the two are never merged. A standalone payment with no metadata carries
none.
Amount rules
amountMinor is an integer count of minor units (paisa) — never floating point.
Beyond the shape check (> 0), the use case enforces the app's configurable
minAmountMinor / maxAmountMinor bounds (PAY-12). An amount below the minimum or
above the maximum is rejected with 400. Currency is BDT-only in v1; any other
code is a 400.
# Rejected: non-BDT currency → 400
curl -X POST http://localhost:8080/api/v1/payments \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" -H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f" \
-H "Content-Type: application/json" \
-d '{ "amountMinor": 150000, "currency": "USD", "customerReference": "cust_4821" }'{
"data": null,
"meta": {
"success": false,
"message": "Unsupported currency 'USD'; only BDT is supported",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:00:00Z"
},
"pagination": null
}Payment response (PaymentDto)
Returned from create (201), the status lookup, and the by-reference list. Null
fields are omitted from the JSON.
| Field | Type | Notes |
|---|---|---|
id | integer (int64) | Internal payment id; use it for GET /payments/{id}. |
reference | string | Opaque public reference (the bearer for hosted checkout/receipt). |
status | string | One of the lifecycle states. Create returns PENDING. |
applicationId | integer (int64) | Owning app id. |
mode | string | TEST or LIVE, derived from the credential. |
amountMinor | integer (int64) | Amount in minor units (paisa). |
currency | string | BDT in v1. |
gateway | string | Gateway name, e.g. SSLCOMMERZ. |
gatewayTxnId | string | Gateway transaction id; set once known. |
checkoutUrl | string | The service-hosted checkout page the app redirects the customer to. |
redirectUrl | string | The gateway-hosted page the checkout page forwards the browser to (set while pending). |
invoiceId | integer (int64) | Linked invoice id, if any. |
metadata | JSON object | Your passthrough metadata, surfaced as a nested JSON object (not a string). |
createdAt | string (date-time) | When the intent was created. |
updatedAt | string (date-time) | Last status change; equals the settlement time once SUCCEEDED. |
Payment lifecycle
Terminal below means the payment will never change status again — you can release the
order, close the cart, or record the loss and never revisit it. Every status in the table is
terminal except CREATED, PENDING and EXPIRED.
| Status | Meaning |
|---|---|
CREATED | Intent persisted, before the checkout session opens. Transient — the create call opens the session in the same transaction and returns PENDING, so you never observe CREATED over the API. |
PENDING | Hosted-checkout session opened; the customer is at the gateway. |
SUCCEEDED | Captured — safe to fulfill. Terminal. |
FAILED | Gateway rejected the attempt. Terminal, not captured. |
CANCELLED | Customer abandoned the attempt. Terminal, not captured. |
EXPIRED | Passed its expiresAt deadline before completing, and was swept. Not captured — but not terminal: see below. |
PARTIALLY_REFUNDED | One or more refunds issued, total less than the amount. Terminal. |
REFUNDED | Fully refunded. Terminal. |
EXPIRED is not the end of the story
We close the checkout on our side when the deadline passes, but the gateway's own session can
outlive it — we have no way to cancel a session at SSLCOMMERZ. A customer who was already on
the gateway's payment page when the deadline passed can still complete the payment, and that
money is real. When the confirmation reaches us we revive the payment to SUCCEEDED and
send payment.succeeded, after having already sent payment.expired.
So payment.expired means "stop waiting for this one", not "this can never succeed". Two
consequences for your integration:
- Anything you do on
payment.expiredmust be reversible, or deferred. Releasing a held cart is fine; refusing to ever fulfil thispayment_idagain is not. - A
payment.succeededfor a payment you already saw expire is not a replay to discard.
For one payment_id you can legitimately receive both, in either order — webhook delivery is
unordered. Rank the statuses rather than trusting arrival order:
| You have stored | Event arrives | Do |
|---|---|---|
expired | payment.succeeded | Fulfil. Undo whatever you did on expiry. |
succeeded | payment.expired | Nothing. A stale expiry for a payment that settled. |
data.previous_status on the succeeded event distinguishes the two cases without keeping state:
"pending" is an ordinary settlement, "expired" is a revival. Full detail in
Late settlement after expiry.
Reading status
Get one payment
GET /payments/{id} returns the payment, scoped to the authenticated app and mode.
A miss (unknown id, or an id belonging to another app or mode) is a 404 — it never
leaks the existence of a cross-tenant record.
curl http://localhost:8080/api/v1/payments/4821 \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" -H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f"{
"data": {
"id": 4821,
"reference": "PAY-7K2QF8M3ND",
"status": "SUCCEEDED",
"applicationId": 42,
"mode": "TEST",
"amountMinor": 150000,
"currency": "BDT",
"gateway": "SSLCOMMERZ",
"gatewayTxnId": "SSLZ-2026063000412",
"checkoutUrl": "http://localhost:3000/checkout/PAY-7K2QF8M3ND",
"metadata": { "orderId": "ord_99812", "cartRef": "cart_5567" },
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:04:18"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:05:00Z" },
"pagination": null
}List a customer's payments
GET /payments?customerReference={ref} returns the app's payments for that customer,
newest first. An unknown reference returns an empty array, not a 404 — a miss
has nothing to leak.
curl "http://localhost:8080/api/v1/payments?customerReference=cust_4821" \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" -H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f"{
"data": [
{ "id": 4821, "reference": "PAY-7K2QF8M3ND", "status": "SUCCEEDED", "amountMinor": 150000, "currency": "BDT", "mode": "TEST", "createdAt": "2026-06-30T12:00:00" },
{ "id": 4733, "reference": "PAY-3D9H2KQ7BW", "status": "EXPIRED", "amountMinor": 50000, "currency": "BDT", "mode": "TEST", "createdAt": "2026-06-29T09:14:02" }
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:05:00Z" },
"pagination": null
}This is identical to GET /customers/{externalId}/payments — both run the same
app- and mode-scoped by-reference lookup. Use whichever fits your code path; see
Look up customers.
Every record carries app_id + mode; one app never sees another app's or another
mode's payments. See App isolation.
Receipts
GET /payments/{id}/receipt returns the receipt payload for a settled payment.
A receipt exists only for a SUCCEEDED payment — a lookup against any other
state (or a cross-app id) is a 404. There is no "receipts enabled" toggle;
settlement is the only gate.
curl http://localhost:8080/api/v1/payments/4821/receipt \
-H "X-Api-Key: oi_test_8f3c1a9b2d4e" -H "X-Api-Secret: sk_test_2b7e9f0a4c6d8e1f"{
"data": {
"paymentId": 4821,
"reference": "PAY-7K2QF8M3ND",
"status": "SUCCEEDED",
"amountMinor": 150000,
"amountDisplay": "1,500.00",
"currency": "BDT",
"customerReference": "cust_4821",
"customerEmail": "[email protected]",
"appName": "Legacy Couch Store",
"gateway": "SSLCOMMERZ",
"paidAt": "2026-06-30T12:04:18"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:05:00Z" },
"pagination": null
}A receipt request for a still-PENDING (or non-existent) payment:
{
"data": null,
"meta": {
"success": false,
"message": "Receipt not found with id: 4821",
"errorCode": "RESOURCE_NOT_FOUND",
"timestamp": "2026-06-30T12:05:00Z"
},
"pagination": null
}Receipt payload (ReceiptDto)
| Field | Type | Notes |
|---|---|---|
paymentId | integer (int64) | The settled payment's id. |
reference | string | Public payment reference. |
status | string | Always SUCCEEDED (the only state with a receipt). |
amountMinor | integer (int64) | Amount in minor units. |
amountDisplay | string | Human-readable amount, e.g. 1,500.00. |
currency | string | BDT. |
customerReference | string | The app's own customer reference. |
customerEmail | string | Customer email, if stored. |
appName | string | The paying merchant's display name. |
gateway | string | Gateway name. |
paidAt | string (date-time) | When the payment settled (pending → succeeded). |
Hosted checkout context (advanced)
The service-hosted checkout page reads GET /checkout/{reference} — an
unauthenticated, reference-keyed endpoint (the opaque reference is the bearer).
It carries no PII and no app-internal data — only what a paying customer is
entitled to see. You normally don't call this directly; the hosted checkout page
does. An unknown reference is a 404.
curl http://localhost:8080/api/v1/checkout/PAY-7K2QF8M3NDCheckout context payload (CheckoutContextDto)
| Field | Type | Notes |
|---|---|---|
reference | string | The payment's opaque reference. |
status | string | Current lifecycle state. |
amountMinor | integer (int64) | Amount in minor units. |
amountDisplay | string | Human-readable amount. |
currency | string | BDT. |
appName | string | The paying merchant's display name. |
gateway | string | Gateway name. |
gatewayRedirectUrl | string | The gateway page the checkout page forwards to; set while PENDING. |
paidAt | string (date-time) | Settlement time; null until settled. |
returnUrl | string | The merchant's deep-link return target for the terminal state (success/fail/cancel); null while pending or when unconfigured. |
Error reference
All errors use the standard envelope with data: null and a machine-readable
meta.errorCode. See Responses & errors.
| HTTP | errorCode | When |
|---|---|---|
400 | VALIDATION_ERROR | Non-positive amount, amount outside the app's min/max, unsupported currency, a return URL outside the app's allow-list, or any field-shape failure (missing customerReference, bad email, oversized field, malformed metadata). |
404 | RESOURCE_NOT_FOUND | Unknown payment id (or cross-app/cross-mode id), a receipt lookup against a non-SUCCEEDED payment, or an unknown checkout reference. |
409 | IDEMPOTENCY_KEY_CONFLICT | An Idempotency-Key was reused with a different body. |
502 | PAYMENT_GATEWAY_ERROR | Opening the hosted-checkout session failed. The whole create transaction rolls back — the payment row and the idempotency-key reservation are released — so a retry is clean rather than burnt. |
On a 502, retry the create. Because the failed attempt rolled back entirely,
reusing the same Idempotency-Key starts fresh — the gateway is never initiated
twice for one logical payment.
Related
Webhooks
Verify the signed payment.succeeded event — the source of truth.
Refunds
Refund a settled payment, fully or partially.
Look up customers
Read a customer and list their payments.
Invoicing
Link a payment to an invoice and inherit its metadata.
Idempotency
Make create-style calls safe to retry.
Modes
How TEST and LIVE are isolated and derived from the credential.
Catalog & subscription data model
How products, prices, benefits, coupons, subscriptions, one-time purchases, and entitlements relate — every entity scoped to an app and mode.
Look up customers
Read one of your customers by your own external id, and list that customer's payments — both scoped to your app (and, for payments, your credential's mode).