OI Payments Docs
Core concepts

Idempotency

Make create operations safe to retry with the Idempotency-Key header — the same key and body replays the first response instead of running twice.

Network calls fail and get retried. Without protection, a retried "create payment" could charge a customer twice, or a retried "create invoice" could issue two invoices. The Idempotency-Key header makes mutating creates safe to retry: the first request runs, and any later request carrying the same key and the same body replays the first response verbatim instead of executing again.

Retries without a key are not safe

A create-style request sent without an Idempotency-Key executes every time it reaches the server. If your client retries on a timeout, you can create two payments. Always send a key on creates you might retry.

How to use it

Generate one key per logical operation

Mint a fresh UUID when you start an operation — one "create payment", one "create invoice", one "create subscription". This is your idempotency key for that operation. Do not generate a new key per network attempt.

Send it on the request

Pass the key in the Idempotency-Key header (alongside your normal API credential headers).

POST /api/v1/payments
X-Api-Key: oi_test_8f3a2b1c9d4e
X-Api-Secret: sk_test_a1b2c3d4e5f6
Idempotency-Key: 9f1c2e44-5a7b-4c3d-9e2f-1a2b3c4d5e6f

Reuse the same key on every retry

If the request times out or you don't get a response, retry with the identical key and body. The server replays the first outcome — the operation runs at most once. Use a new key only when you genuinely want a new operation.

Behaviour guarantees

Idempotency is handled by a cross-cutting aspect (@Idempotent) that wraps the create use case. The guarantees, exactly as the server enforces them:

  • Scope is per (app, key). The key is scoped to the app that authenticated the request — the authenticated applicationId plus the key string. Two different apps can use the same key string without colliding, and one app never sees another app's keys. As always, the mode (TEST or LIVE) comes from the credential, never the body.
  • First call executes and snapshots. The first request with a given key reserves the key, runs the operation, and stores a snapshot of the response — the reservation, the operation's writes, and the snapshot all commit in one transaction, so there is no window where the work happened but a retry can't see it.
  • Same key + same body replays verbatim. A repeat with the same key whose body matches the original (compared by a SHA-256 fingerprint of the request) returns the stored response without re-executing. You get the same status code and the same body as the first call.
  • Omit the header and it runs every time. Idempotency is opt-in. With no Idempotency-Key header, the request executes normally on every call — there is no dedupe.
  • A failed operation frees the key. If the operation throws, the whole transaction — including the reservation — rolls back. The key is left clean, so you can retry it with a fresh attempt. A failed attempt never "burns" the key.

Request flow

Record lifecycle

A reservation row moves through two states. Because everything commits atomically, a committed row is always COMPLETED; a row seen as IN_PROGRESS is an uncommitted reservation held by the executing transaction.

Worked example: create a payment

The response envelope is the standard one — { "data": ..., "meta": ... }; the examples below show the data payload. Read more under responses & errors.

Request body — CreatePaymentRequest

FieldTypeRequiredNotes
amountMinorinteger (int64)YesAmount in integer minor units (paisa): 150000 = 1,500.00 BDT. Must be positive. Never floating point — see money.
currencystringNoISO-4217, max 3 chars. Defaults to BDT; any other code is rejected with 400.
customerReferencestringYesYour own id for the paying customer. Max 255 chars, not blank.
customerEmailstringNoValid email, max 320. Pre-fills the gateway page only.
customerPhonestringNoMax 32. Pre-fills the gateway page only.
customerNamestringNoMax 255. Shown on the hosted page; not persisted.
productNamestringNoMax 255. Short description shown on the hosted page.
invoiceIdinteger (int64)NoInvoice this payment settles; null for a standalone payment.
metadataJSON objectNoOptional JSON object stored as-is and echoed back unchanged on the settlement webhook; it is never interpreted or merged. Size/depth/key-count bounded.
successUrlstringNoReturn URL on success, max 2048. Validated against the app's allow-list.
failUrlstringNoReturn URL on failure, max 2048. Same allow-list rules.
cancelUrlstringNoReturn URL on cancel, max 2048. Same allow-list rules.
curl -X POST http://localhost:8080/api/v1/payments \
  -H "X-Api-Key: oi_test_8f3a2b1c9d4e" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 9f1c2e44-5a7b-4c3d-9e2f-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{
    "amountMinor": 150000,
    "currency": "BDT",
    "customerReference": "cust_4821",
    "customerEmail": "[email protected]",
    "productName": "Annual membership",
    "metadata": { "orderId": "ORD-90210" }
  }'

The first call executes and returns 201 Created:

{
  "data": {
    "id": 7781,
    "reference": "pay_2f8c1a9b",
    "status": "PENDING",
    "applicationId": 42,
    "mode": "TEST",
    "amountMinor": 150000,
    "currency": "BDT",
    "gateway": "SSLCOMMERZ",
    "checkoutUrl": "http://localhost:8080/api/v1/checkout/pay_2f8c1a9b",
    "metadata": { "orderId": "ORD-90210" },
    "createdAt": "2026-06-30T12:00:00Z",
    "updatedAt": "2026-06-30T12:00:00Z"
  },
  "meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
  "pagination": null
}

Replaying the exact same request (same key, same body) replays the stored data payload — the same id, reference, and checkoutUrl, returned again with 201 — without creating a second payment. Your customer is redirected to the same checkout. (Only the envelope's meta.timestamp is regenerated on the replay.)

Key response fields — PaymentDto

FieldTypeNotes
idinteger (int64)Server payment id.
referencestringStable public payment reference.
statusstringPayment status (e.g. PENDING, SUCCESS, FAILED).
applicationIdinteger (int64)Owning app.
modestringTEST or LIVE, from the credential.
amountMinorinteger (int64)Amount in minor units (paisa).
currencystringAlways BDT in v1.
gatewaystringGateway that will process the charge.
checkoutUrlstringService-hosted checkout URL to redirect the customer to.
redirectUrlstringGateway-hosted page the checkout forwards to. Omitted until issued.
invoiceIdinteger (int64)Settled invoice, when applicable. Omitted when none.
metadataJSON objectYour passthrough metadata, echoed back. Omitted when none.
createdAt / updatedAtstring (date-time)Audit timestamps.

Conflicts

Both conflict cases return HTTP 409 and never execute a second operation:

errorCodeHTTPWhen
IDEMPOTENCY_KEY_CONFLICT409The same key was reused with a different request body.
IDEMPOTENCY_IN_PROGRESS409A request with the same key is still being processed — retry shortly.

A 409 IDEMPOTENCY_KEY_CONFLICT almost always means a client bug — the same key is being reused for two different payloads. Generate a new key for a new operation.

# Same key as before, but a different amount -> rejected
curl -X POST http://localhost:8080/api/v1/payments \
  -H "X-Api-Key: oi_test_8f3a2b1c9d4e" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 9f1c2e44-5a7b-4c3d-9e2f-1a2b3c4d5e6f" \
  -H "Content-Type: application/json" \
  -d '{ "amountMinor": 250000, "customerReference": "cust_4821" }'
{
  "data": null,
  "meta": {
    "success": false,
    "message": "Idempotency-Key '9f1c2e44-5a7b-4c3d-9e2f-1a2b3c4d5e6f' was already used with a different request payload",
    "errorCode": "IDEMPOTENCY_KEY_CONFLICT",
    "timestamp": "2026-06-30T12:00:05Z"
  },
  "pagination": null
}

Where it applies

The header works on every create-style mutation below. These are all app-API endpoints — authenticate with X-Api-Key + X-Api-Secret, then add Idempotency-Key.

Method & pathCreates
POST /api/v1/paymentsA payment intent — see accept a payment.
POST /api/v1/payments/{id}/refundsA refund against a payment — see refunds.
POST /api/v1/invoicesAn invoice — see invoicing.
POST /api/v1/productsA product — see products & prices.
POST /api/v1/products/{id}/pricesA price on a product.
POST /api/v1/benefitsA benefit — see entitlements.
POST /api/v1/products/{id}/benefitsAttaches a benefit to a product.
POST /api/v1/subscriptionsA subscription — see subscriptions.
POST /api/v1/purchasesA one-time purchase — see one-time purchases.
POST /api/v1/couponsA coupon — see coupons.

Subscriptions and coupons

The header behaves identically everywhere. Two more examples:

# Create a subscription, safe to retry
curl -X POST http://localhost:8080/api/v1/subscriptions \
  -H "X-Api-Key: oi_test_8f3a2b1c9d4e" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 3c1d5e77-9a2b-4f6c-8d0e-7a1b2c3d4e5f" \
  -H "Content-Type: application/json" \
  -d '{ "priceId": 5012, "customerReference": "cust_4821", "couponCode": "LAUNCH20" }'
FieldTypeRequiredNotes
priceIdinteger (int64)YesRecurring price to bill against.
customerReferencestringYesYour own customer id; find-or-created.
customerName / customerEmail / customerPhonestringNoRefreshed on the customer when supplied.
startDatestring (date)NoDefaults to today.
couponCodestringNoCoupon code to apply; unknown/non-redeemable rolls back the create.
# Create a coupon, safe to retry
curl -X POST http://localhost:8080/api/v1/coupons \
  -H "X-Api-Key: oi_test_8f3a2b1c9d4e" \
  -H "X-Api-Secret: sk_test_a1b2c3d4e5f6" \
  -H "Idempotency-Key: 6b2e9c10-4d3f-4a8b-9c1d-2e3f4a5b6c7d" \
  -H "Content-Type: application/json" \
  -d '{
    "code": "LAUNCH20",
    "name": "Launch 20% off",
    "type": "PERCENT",
    "value": 20,
    "duration": "REPEATING",
    "durationCycles": 3
  }'
FieldTypeRequiredNotes
codestringYesRedemption handle; unique per app + mode, max 64.
namestringYesDisplay name, max 255.
typeenumYesPERCENT (value 1..100) or FIXED (value > 0, in minor units).
valueinteger (int64)YesPositive; meaning depends on type.
durationenumYesONCE, REPEATING, or FOREVER.
durationCyclesintegerConditionalRequired (≥1) for REPEATING; must be absent for ONCE/FOREVER.
expiresAtstring (date-time)NoAfter this instant the coupon is no longer redeemable.

What is not idempotent

  • Reads (GET) are naturally idempotent and need no key — calling one twice changes nothing.
  • The admin refund endpoint POST /api/v1/admin/payments/{id}/refunds is not idempotent. It runs through the admin (session-authenticated) refund use case, which does not carry the @Idempotent aspect. The Idempotency-Key header is ignored there. Idempotent refunds are available on the app-API path POST /api/v1/payments/{id}/refunds. The admin dashboard and analytics endpoints are reads and are likewise unaffected.

The idempotency aspect resolves the authenticated app to scope the key. It only sits on the app-API create use cases — never on admin/session use cases — so sending the header to an admin endpoint has no effect.

On this page