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
ACTIVEproduct — aDRAFTorARCHIVEDproduct is not sellable. - An active
ONE_TIMEprice on that product — created viaPOST /products/{id}/priceswithtype: "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.
Share the payable link
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:
| Field | Type | Required | Notes |
|---|---|---|---|
priceId | number (int64) | yes | Id 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. |
customerReference | string | yes | Your identifier for the customer. Find-or-creates the app's customer record. Must be non-blank. |
customerName | string | no | Display name; refreshed on the customer record when supplied. |
customerEmail | string | no | Contact email; refreshed on the customer record when supplied. |
customerPhone | string | no | Contact 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.
| Field | Type | Required | Notes |
|---|---|---|---|
invoiceId | number (int64) | yes | The issued invoice's internal id. Read it back via GET /invoices/{id}. |
invoiceNumber | string | yes | The race-safe per-app invoice number. |
status | string | yes | The invoice status — ISSUED on creation. |
totalMinor | number (int64) | yes | The purchase total in integer minor units (e.g. 150000 = 1,500.00 BDT). Equals the price amount. |
currency | string | yes | ISO-4217 currency, BDT in v1. |
priceId | number (int64) | yes | The bought ONE_TIME price, pinned onto the invoice. Entitlements are derived from it once paid. |
productId | number (int64) | yes | The product the price belongs to. |
payableLink | object | yes | The hosted-checkout link the customer settles through. See below. |
payableLink (PayableLinkDto):
| Field | Type | Required | Notes |
|---|---|---|---|
payableUrl | string | yes | The 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.
| HTTP | errorCode | When | Example message |
|---|---|---|---|
404 | RESOURCE_NOT_FOUND | priceId does not resolve to a price visible in the authenticated app + mode. | Price not found with id: 4021 |
422 | INVALID_OPERATION_STATE | The owning product is not ACTIVE (e.g. DRAFT or ARCHIVED). | The product is not active/sellable; it is ARCHIVED |
400 | VALIDATION_ERROR | The price is recurring, not ONE_TIME (use Subscriptions instead). | A one-time purchase requires a one-time price |
422 | INVALID_OPERATION_STATE | The price exists but has been archived. | The price is archived and cannot be purchased |
400 | VALIDATION_ERROR | A required field is missing/blank (priceId, customerReference). | priceId is required |
409 | IDEMPOTENCY_KEY_CONFLICT | The 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:
- The customer pays on the hosted checkout. SSLCOMMERZ calls the service back, and the service verifies the callback.
- Money posts to the double-entry ledger and the invoice moves to
PAID(PARTIALLY_PAIDfirst if it is settled in installments). payment.succeededandinvoice.paidwebhooks fire — the same events any payment or invoice emits. There is nopurchase.*event.- Because the paid invoice carries a
price_id(and no subscription), the customer's derived entitlements are recomputed and asubscription.entitlements_updatedwebhook 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):
150000means 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/LIVEmode 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.
Related
Products & prices
Create the ACTIVE product and ONE_TIME price a purchase sells.
Entitlements
What a paid purchase grants, and how to gate access.
Accept a payment
The hosted-checkout flow the payable link uses.
Invoicing
The invoice a purchase issues, and its lifecycle.
Subscriptions
Recurring billing — the RECURRING-price sibling of this flow.
Webhooks
The payment.* / invoice.* / subscription.entitlements_updated events you react to.
Coupons
Author a per-app coupon catalogue, redeem a coupon by code when creating a subscription, and let the billing engine apply the discount per cycle.
Entitlements
Gate features from purchases and subscriptions. Entitlements are derived on the fly from the benefits a customer's paid purchases and active subscriptions grant — there is no entitlement table.