OI Payments Docs
Guides

Sell a one-time product

Charge once for a product through a ONE_TIME price — it issues an invoice with the bought price pinned and a payable link settled by the v1 payment flow, then grants entitlements when paid.

A one-time purchase charges a customer once for a product through one of its ONE_TIME prices — no recurring billing, no subscription. It is deliberately thin: a purchase owns no table of its own. It is an invoice with the bought price_id pinned onto it, plus a hosted-checkout payable link. The customer settles it through the same v1 payment flow you already use, and when the invoice reaches PAID the purchase grants the product's entitlements.

There is no purchase.* webhook. A one-time purchase reuses the existing payment.* and invoice.* events — there are no new event types for it.

Reach for a one-time purchase when you want catalog-driven, entitlement-granting one-off sales (a course, a lifetime license, a one-off add-on). For an ad-hoc amount with no catalog product, use Accept a payment or Invoicing directly. For recurring billing, use Subscriptions.

Prerequisites

Before you can sell a product one-time, you need:

  • An ACTIVE product — a DRAFT or ARCHIVED product is not sellable.
  • An active ONE_TIME price on that product — created via POST /products/{id}/prices with type: "ONE_TIME". An archived price cannot be purchased. See Products & prices.

The price carries the amount (in minor units) and currency; the purchase does not take an amount or currency of its own — it inherits them from the price. Currency is BDT-only in v1.

The mode (TEST or LIVE) is derived from the API credential that authenticated the request — never from the request body. A purchase, its invoice, and its price are all resolved within that one app + mode, so you can never buy against another app's or another mode's price (see App isolation).

The flow

Create the purchase

POST /purchases with the priceId of a ONE_TIME price and your customerReference. Send an Idempotency-Key so a retry can't issue the invoice (and burn its number) twice.

curl -X POST http://localhost:8080/api/v1/purchases \
  -H "X-Api-Key: oi_test_51f8c2" -H "X-Api-Secret: your-secret" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f1c2e44-7b3a-4f2e-9c1d-2a6b5e0f7c11" \
  -d '{
    "priceId": 4021,
    "customerReference": "cust_7Gd2",
    "customerName": "Ayesha Rahman",
    "customerEmail": "[email protected]",
    "customerPhone": "+8801712345678"
  }'

The customer is find-or-created from customerReference; the optional customerName / customerEmail / customerPhone refresh that customer's contact details when supplied.

Read the response

You get back 201 Created with a OneTimePurchaseDto. The issued invoice starts at status: "ISSUED". Persist the invoiceId / invoiceNumber and grab the payableLink.payableUrl to hand to the customer.

Send the customer to payableLink.payableUrl. They land on the same hosted checkout / branded payable invoice page used by the rest of the platform — your app never sees card data.

React to the confirmed result

When the customer pays, the gateway calls the service back server-to-server. The service verifies the callback, posts the money to the ledger, moves the invoice to PAID, and emits payment.succeeded and invoice.paid webhooks. Because the paid invoice carries a price_id, the customer's entitlements are recomputed and a subscription.entitlements_updated webhook fires. Treat the webhook as the source of truth — never grant value off the browser redirect alone.

Request body

CreateOneTimePurchaseRequest:

FieldTypeRequiredNotes
priceIdnumber (int64)yesId of a ONE_TIME price on an ACTIVE product. Resolved within the authenticated app + mode; a miss is 404. A recurring price is rejected with 400.
customerReferencestringyesYour identifier for the customer. Find-or-creates the app's customer record. Must be non-blank.
customerNamestringnoDisplay name; refreshed on the customer record when supplied.
customerEmailstringnoContact email; refreshed on the customer record when supplied.
customerPhonestringnoContact phone; refreshed on the customer record when supplied.

There is no amount, currency, coupon, or start date on this request — a one-time purchase has no billing calendar, and the amount + currency come from the price.

metadata is not accepted on the purchase create. If you need metadata passthrough, note that metadata is an optional JSON object stored as-is and echoed back unchanged on the settlement webhook; it is never interpreted or merged.

Idempotency

The create accepts an optional Idempotency-Key header. A repeat with the same key and the same body replays the first response, so the invoice is issued — and its per-app number burnt — at most once. A repeat with the same key but a different body is rejected with IDEMPOTENCY_KEY_CONFLICT.

Response

OneTimePurchaseDto (a one-time purchase has no resource of its own — this is a view over the issued invoice plus its payable link). Null fields are omitted.

FieldTypeRequiredNotes
invoiceIdnumber (int64)yesThe issued invoice's internal id. Read it back via GET /invoices/{id}.
invoiceNumberstringyesThe race-safe per-app invoice number.
statusstringyesThe invoice status — ISSUED on creation.
totalMinornumber (int64)yesThe purchase total in integer minor units (e.g. 150000 = 1,500.00 BDT). Equals the price amount.
currencystringyesISO-4217 currency, BDT in v1.
priceIdnumber (int64)yesThe bought ONE_TIME price, pinned onto the invoice. Entitlements are derived from it once paid.
productIdnumber (int64)yesThe product the price belongs to.
payableLinkobjectyesThe hosted-checkout link the customer settles through. See below.

payableLink (PayableLinkDto):

FieldTypeRequiredNotes
payableUrlstringyesThe service-hosted checkout URL to share with the customer.

Every endpoint returns the standard envelope: { "data": <payload|null>, "meta": { "success", "message", "errorCode", "timestamp" }, "pagination": null }. A successful create:

{
  "data": {
    "invoiceId": 80231,
    "invoiceNumber": "INV-2026-000457",
    "status": "ISSUED",
    "totalMinor": 150000,
    "currency": "BDT",
    "priceId": 4021,
    "productId": 512,
    "payableLink": {
      "payableUrl": "http://localhost:3000/pay/pay_7c1e9a4b"
    }
  },
  "meta": {
    "success": true,
    "message": null,
    "errorCode": null,
    "timestamp": "2026-06-30T12:00:00Z"
  },
  "pagination": null
}

Errors

The use case resolves the price first (404 if it isn't visible in this app + mode), then validates that it is purchasable in this order: the owning product is ACTIVE, the price is ONE_TIME, and the price is still active.

HTTPerrorCodeWhenExample message
404RESOURCE_NOT_FOUNDpriceId does not resolve to a price visible in the authenticated app + mode.Price not found with id: 4021
422INVALID_OPERATION_STATEThe owning product is not ACTIVE (e.g. DRAFT or ARCHIVED).The product is not active/sellable; it is ARCHIVED
400VALIDATION_ERRORThe price is recurring, not ONE_TIME (use Subscriptions instead).A one-time purchase requires a one-time price
422INVALID_OPERATION_STATEThe price exists but has been archived.The price is archived and cannot be purchased
400VALIDATION_ERRORA required field is missing/blank (priceId, customerReference).priceId is required
409IDEMPOTENCY_KEY_CONFLICTThe Idempotency-Key was reused with a different body.

A 400 for a recurring price:

{
  "data": null,
  "meta": {
    "success": false,
    "message": "A one-time purchase requires a one-time price",
    "errorCode": "VALIDATION_ERROR",
    "timestamp": "2026-06-30T12:00:00Z"
  },
  "pagination": null
}

A 422 for an archived price:

{
  "data": null,
  "meta": {
    "success": false,
    "message": "The price is archived and cannot be purchased",
    "errorCode": "INVALID_OPERATION_STATE",
    "timestamp": "2026-06-30T12:00:00Z"
  },
  "pagination": null
}

A 404 when the price isn't in this app + mode:

{
  "data": null,
  "meta": {
    "success": false,
    "message": "Price not found with id: 4021",
    "errorCode": "RESOURCE_NOT_FOUND",
    "timestamp": "2026-06-30T12:00:00Z"
  },
  "pagination": null
}

What happens when the customer pays

The purchase rides the existing v1 settlement machinery — nothing purchase-specific:

  1. The customer pays on the hosted checkout. SSLCOMMERZ calls the service back, and the service verifies the callback.
  2. Money posts to the double-entry ledger and the invoice moves to PAID (PARTIALLY_PAID first if it is settled in installments).
  3. payment.succeeded and invoice.paid webhooks fire — the same events any payment or invoice emits. There is no purchase.* event.
  4. Because the paid invoice carries a price_id (and no subscription), the customer's derived entitlements are recomputed and a subscription.entitlements_updated webhook fires so you can invalidate any cached gate decisions.

After that, the granted benefits show up in the customer's active entitlements:

curl "http://localhost:8080/api/v1/entitlements?customerReference=cust_7Gd2" \
  -H "X-Api-Key: oi_test_51f8c2" -H "X-Api-Secret: your-secret"

Entitlements are derived on read (there is no entitlement table), so a paid one-time purchase contributes its product's benefits to the union the moment its invoice is PAID.

Notes

  • Money is integer minor units (paisa): 150000 means 1,500.00 BDT. Never use floating point. Currency is BDT-only in v1 — any other code is rejected.
  • Mode comes from the credential. The TEST / LIVE mode is derived from the API key that authenticated the request — never from the request body. Test and live purchases are fully isolated.
  • App isolation. Every record carries app_id + mode; one app never sees another app's (or another mode's) purchases, invoices, or prices. See App isolation.

On this page