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
| 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. |
FAILED | Gateway rejected the attempt. Terminal, not captured. |
CANCELLED | Customer abandoned the attempt. Terminal, not captured. |
EXPIRED | Sat PENDING past the expiry window (default 30 min) and was swept. Terminal, not captured. |
PARTIALLY_REFUNDED | One or more refunds issued, total less than the amount. |
REFUNDED | Fully refunded. |
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).