OI Payments Docs
Guides

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.

FieldTypeRequiredNotes
amountMinorinteger (int64)yesInteger 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.
currencystringnoMax 3 chars. Defaults to BDT. BDT-only in v1 — any other value is rejected with 400 Unsupported currency.
customerReferencestringyesYour own identifier for the customer (not our id). Max 255 chars, non-blank. Find-or-creates the customer record.
customerEmailstringnoValid email, max 320 chars. Pre-fills the gateway page; refreshes the stored customer contact.
customerPhonestringnoMax 32 chars. Pre-fills the gateway page; refreshes the stored customer contact.
customerNamestringnoMax 255 chars. Shown on the gateway page; refreshes the stored customer contact.
productNamestringnoMax 255 chars. Short description shown on the hosted checkout page.
invoiceIdinteger (int64)noLinks this payment to an invoice; null for a standalone payment.
metadataJSON objectnoBounded passthrough object (see below). Echoed unchanged on the settlement webhook.
successUrlstringnoMax 2048 chars. Browser return URL on success; allow-list validated (see below). Custom schemes (deep links) allowed.
failUrlstringnoMax 2048 chars. Browser return URL on a failed attempt; same allow-list rules.
cancelUrlstringnoMax 2048 chars. Browser return URL on a cancelled attempt; same allow-list rules.

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.

FieldTypeNotes
idinteger (int64)Internal payment id; use it for GET /payments/{id}.
referencestringOpaque public reference (the bearer for hosted checkout/receipt).
statusstringOne of the lifecycle states. Create returns PENDING.
applicationIdinteger (int64)Owning app id.
modestringTEST or LIVE, derived from the credential.
amountMinorinteger (int64)Amount in minor units (paisa).
currencystringBDT in v1.
gatewaystringGateway name, e.g. SSLCOMMERZ.
gatewayTxnIdstringGateway transaction id; set once known.
checkoutUrlstringThe service-hosted checkout page the app redirects the customer to.
redirectUrlstringThe gateway-hosted page the checkout page forwards the browser to (set while pending).
invoiceIdinteger (int64)Linked invoice id, if any.
metadataJSON objectYour passthrough metadata, surfaced as a nested JSON object (not a string).
createdAtstring (date-time)When the intent was created.
updatedAtstring (date-time)Last status change; equals the settlement time once SUCCEEDED.

Payment lifecycle

StatusMeaning
CREATEDIntent 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.
PENDINGHosted-checkout session opened; the customer is at the gateway.
SUCCEEDEDCaptured — safe to fulfill.
FAILEDGateway rejected the attempt. Terminal, not captured.
CANCELLEDCustomer abandoned the attempt. Terminal, not captured.
EXPIREDSat PENDING past the expiry window (default 30 min) and was swept. Terminal, not captured.
PARTIALLY_REFUNDEDOne or more refunds issued, total less than the amount.
REFUNDEDFully 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)

FieldTypeNotes
paymentIdinteger (int64)The settled payment's id.
referencestringPublic payment reference.
statusstringAlways SUCCEEDED (the only state with a receipt).
amountMinorinteger (int64)Amount in minor units.
amountDisplaystringHuman-readable amount, e.g. 1,500.00.
currencystringBDT.
customerReferencestringThe app's own customer reference.
customerEmailstringCustomer email, if stored.
appNamestringThe paying merchant's display name.
gatewaystringGateway name.
paidAtstring (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-7K2QF8M3ND

Checkout context payload (CheckoutContextDto)

FieldTypeNotes
referencestringThe payment's opaque reference.
statusstringCurrent lifecycle state.
amountMinorinteger (int64)Amount in minor units.
amountDisplaystringHuman-readable amount.
currencystringBDT.
appNamestringThe paying merchant's display name.
gatewaystringGateway name.
gatewayRedirectUrlstringThe gateway page the checkout page forwards to; set while PENDING.
paidAtstring (date-time)Settlement time; null until settled.
returnUrlstringThe 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.

HTTPerrorCodeWhen
400VALIDATION_ERRORNon-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).
404RESOURCE_NOT_FOUNDUnknown payment id (or cross-app/cross-mode id), a receipt lookup against a non-SUCCEEDED payment, or an unknown checkout reference.
409IDEMPOTENCY_KEY_CONFLICTAn Idempotency-Key was reused with a different body.
502PAYMENT_GATEWAY_ERROROpening 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.

On this page