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-1a2b3c4d5e6fReuse 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 authenticatedapplicationIdplus 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 (TESTorLIVE) 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-Keyheader, 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
| Field | Type | Required | Notes |
|---|---|---|---|
amountMinor | integer (int64) | Yes | Amount in integer minor units (paisa): 150000 = 1,500.00 BDT. Must be positive. Never floating point — see money. |
currency | string | No | ISO-4217, max 3 chars. Defaults to BDT; any other code is rejected with 400. |
customerReference | string | Yes | Your own id for the paying customer. Max 255 chars, not blank. |
customerEmail | string | No | Valid email, max 320. Pre-fills the gateway page only. |
customerPhone | string | No | Max 32. Pre-fills the gateway page only. |
customerName | string | No | Max 255. Shown on the hosted page; not persisted. |
productName | string | No | Max 255. Short description shown on the hosted page. |
invoiceId | integer (int64) | No | Invoice this payment settles; null for a standalone payment. |
metadata | JSON object | No | Optional JSON object stored as-is and echoed back unchanged on the settlement webhook; it is never interpreted or merged. Size/depth/key-count bounded. |
successUrl | string | No | Return URL on success, max 2048. Validated against the app's allow-list. |
failUrl | string | No | Return URL on failure, max 2048. Same allow-list rules. |
cancelUrl | string | No | Return 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
| Field | Type | Notes |
|---|---|---|
id | integer (int64) | Server payment id. |
reference | string | Stable public payment reference. |
status | string | Payment status (e.g. PENDING, SUCCESS, FAILED). |
applicationId | integer (int64) | Owning app. |
mode | string | TEST or LIVE, from the credential. |
amountMinor | integer (int64) | Amount in minor units (paisa). |
currency | string | Always BDT in v1. |
gateway | string | Gateway that will process the charge. |
checkoutUrl | string | Service-hosted checkout URL to redirect the customer to. |
redirectUrl | string | Gateway-hosted page the checkout forwards to. Omitted until issued. |
invoiceId | integer (int64) | Settled invoice, when applicable. Omitted when none. |
metadata | JSON object | Your passthrough metadata, echoed back. Omitted when none. |
createdAt / updatedAt | string (date-time) | Audit timestamps. |
Conflicts
Both conflict cases return HTTP 409 and never execute a second operation:
errorCode | HTTP | When |
|---|---|---|
IDEMPOTENCY_KEY_CONFLICT | 409 | The same key was reused with a different request body. |
IDEMPOTENCY_IN_PROGRESS | 409 | A 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 & path | Creates |
|---|---|
POST /api/v1/payments | A payment intent — see accept a payment. |
POST /api/v1/payments/{id}/refunds | A refund against a payment — see refunds. |
POST /api/v1/invoices | An invoice — see invoicing. |
POST /api/v1/products | A product — see products & prices. |
POST /api/v1/products/{id}/prices | A price on a product. |
POST /api/v1/benefits | A benefit — see entitlements. |
POST /api/v1/products/{id}/benefits | Attaches a benefit to a product. |
POST /api/v1/subscriptions | A subscription — see subscriptions. |
POST /api/v1/purchases | A one-time purchase — see one-time purchases. |
POST /api/v1/coupons | A 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" }'| Field | Type | Required | Notes |
|---|---|---|---|
priceId | integer (int64) | Yes | Recurring price to bill against. |
customerReference | string | Yes | Your own customer id; find-or-created. |
customerName / customerEmail / customerPhone | string | No | Refreshed on the customer when supplied. |
startDate | string (date) | No | Defaults to today. |
couponCode | string | No | Coupon 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
}'| Field | Type | Required | Notes |
|---|---|---|---|
code | string | Yes | Redemption handle; unique per app + mode, max 64. |
name | string | Yes | Display name, max 255. |
type | enum | Yes | PERCENT (value 1..100) or FIXED (value > 0, in minor units). |
value | integer (int64) | Yes | Positive; meaning depends on type. |
duration | enum | Yes | ONCE, REPEATING, or FOREVER. |
durationCycles | integer | Conditional | Required (≥1) for REPEATING; must be absent for ONCE/FOREVER. |
expiresAt | string (date-time) | No | After 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}/refundsis not idempotent. It runs through the admin (session-authenticated) refund use case, which does not carry the@Idempotentaspect. TheIdempotency-Keyheader is ignored there. Idempotent refunds are available on the app-API pathPOST /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.
Related
App isolation
Keys are scoped per app — one app never sees another's keys or data.
Test & live modes
Mode comes from the credential that authenticated the request, never the body.
Responses & errors
The ApiResult envelope and the error codes returned on 409 conflicts.
Accept a payment
The end-to-end create-payment flow that this header protects.
Customers
A customer is a lightweight reference — your own external id plus optional contact fields — find-or-created from the payments and invoices you raise. No standalone create endpoint, and no required PII.
Pagination & filtering
How list and search endpoints page, sort, and filter — the pagination block, app-API Pageable params, admin sortBy/order/paginate params, and unpaginated lists.