Invoicing
Create itemized invoices, issue them with a per-app number, mint a hosted payable link, brand the PDF, and track installments through to paid.
An invoice is an itemized bill you raise for one of your customers and collect — in
full or in installments — through the same hosted checkout that backs a one-off
payment. You build it from line items, optionally
issue it immediately (assigning a per-app number), mint a payable link the customer
pays at, and watch it move toward PAID as money lands.
All amounts are integer minor units (paisa): 150000
means 1,500.00 BDT — never floating point. Currency is BDT-only in v1; any other
code is rejected. The mode (TEST or LIVE) is derived from the API credential
that authenticated the request — never from the request body — so test and live
invoices are fully isolated and an invoice from
another app or mode simply reads as a 404.
The app-facing invoicing endpoints all authenticate with your API key pair:
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"Create an invoice
POST /invoices with one or more line items. The total is computed server-side as
the sum of unitAmountMinor × qty across the lines and must be positive.
curl -X POST http://localhost:8080/api/v1/invoices \
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9f1c2e44-7a3b-4c10-b8d2-2f0c5e6a1b77" \
-d '{
"customerReference": "cust_8a2f19",
"customerEmail": "[email protected]",
"customerPhone": "+8801700000000",
"currency": "BDT",
"dueDate": "2026-07-15",
"issue": true,
"lineItems": [
{ "description": "Setup fee", "qty": 1, "unitAmountMinor": 50000 },
{ "description": "Monthly plan", "qty": 2, "unitAmountMinor": 25000 }
],
"metadata": { "orderId": "ord_5567", "channel": "web" }
}'Request body
| Field | Type | Required | Notes |
|---|---|---|---|
customerReference | string | Yes | Max 255. Your own customer id; find-or-creates the customer record for this app. |
customerEmail | string | No | Valid email, max 320. Persisted only when the customer reference is first created — it does not overwrite an existing contact. |
customerPhone | string | No | Max 32. Persisted only when the customer reference is first created. |
currency | string | No | ISO-4217, max 3. Defaults to BDT; any other code is rejected with 400. |
dueDate | string (ISO date) | No | e.g. 2026-07-15. Drives the overdue sweep; no due date means the invoice never goes overdue. |
issue | boolean | No | Defaults to true. true issues immediately (status ISSUED, emits invoice.issued); false keeps it a DRAFT. |
lineItems | array | Yes | At least one line item. |
lineItems[].description | string | Yes | Non-blank, max 512. |
lineItems[].qty | integer | Yes | A positive whole number (> 0). |
lineItems[].unitAmountMinor | integer (minor units) | Yes | Zero or a positive number of minor units (>= 0). The line and invoice total must still come out positive. |
metadata | object | No | Optional JSON object stored as-is and echoed back unchanged; it is never interpreted or merged. Bounded by size / depth / key-count. A payment later raised for this invoice inherits it when the payment request supplies no metadata of its own. |
Every endpoint wraps its payload in the standard envelope — { "data": …, "meta": { "success", "message", "errorCode", "timestamp" }, "pagination": … }. The rest of
this page shows just the data payload.
Response — InvoiceDto
{
"data": {
"id": 4012,
"number": "INV-000001",
"status": "ISSUED",
"mode": "TEST",
"applicationId": 42,
"customerReference": "cust_8a2f19",
"totalMinor": 100000,
"amountPaidMinor": 0,
"amountDueMinor": 100000,
"currency": "BDT",
"dueDate": "2026-07-15",
"issuedAt": "2026-06-30T12:00:00",
"lineItems": [
{ "id": 9001, "description": "Setup fee", "qty": 1, "unitAmountMinor": 50000, "lineTotalMinor": 50000 },
{ "id": 9002, "description": "Monthly plan","qty": 2, "unitAmountMinor": 25000, "lineTotalMinor": 50000 }
],
"payments": [],
"metadata": { "orderId": "ord_5567", "channel": "web" },
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:00:00"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": null
}| Field | Type | Required | Notes |
|---|---|---|---|
id | number | Yes | Internal invoice id (used in the /{id}/… sub-routes). |
number | string | Yes | Per-app invoice number, e.g. INV-000001 (assigned at creation — see below). |
status | string | Yes | One of DRAFT, ISSUED, PARTIALLY_PAID, PAID, VOID, OVERDUE. |
mode | string | Yes | TEST or LIVE — derived from the authenticating credential. |
applicationId | number | Yes | Owning app id. |
customerReference | string | Yes | Your external customer id this invoice bills. |
totalMinor | integer (minor units) | Yes | Sum of lineTotalMinor across the lines. |
amountPaidMinor | integer (minor units) | Yes | Running total settled by payments; starts at 0. |
amountDueMinor | integer (minor units) | Yes | Outstanding balance = totalMinor − amountPaidMinor. |
currency | string | Yes | Always BDT in v1. |
dueDate | string (ISO date) | No | Omitted when none was supplied. |
issuedAt | string (date-time) | No | When it was issued; null/omitted while a DRAFT. |
lineItems[] | array | Yes | The billed lines (see below). |
payments[] | array | Yes | The payment(s) settling this invoice; empty until a payable link is paid. |
metadata | object | No | The JSON object you attached at creation, echoed back unchanged; omitted when none. |
createdAt / updatedAt | string (date-time) | Yes | Audit timestamps. |
customerName, customerEmail, customerPhone, subscriptionId, priceId,
applicationName, and payableUrl exist on the read model but are resolved only
by the admin detail view or for subscription-issued invoices — they are omitted
(NON_NULL) from this app-facing response. To mint the payable link, use the
dedicated endpoint below rather than reading payableUrl.
Line item — InvoiceLineItemDto
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | Yes | Line item id. |
description | string | Yes | The billed item. |
qty | integer | Yes | Quantity. |
unitAmountMinor | integer (minor units) | Yes | Price per unit. |
lineTotalMinor | integer (minor units) | Yes | unitAmountMinor × qty for this line. |
In the example above: 50000 + (2 × 25000) = 100000 — i.e. 1,000.00 BDT.
Invoice numbering
Each issued number comes from a per-app atomic counter. Numbers are formatted as
the app's prefix plus a zero-padded six-digit sequence — INV-000001,
INV-000002, and so on. PostgreSQL takes a row lock on the counter for the
transaction, so concurrent creators for the same app serialise and each gets a
distinct value (no read-modify-write race). Numbers are unique within an app and
mostly sequential; a rolled-back create simply leaves a gap.
A number is allocated for every created invoice — drafts included. The counter
advances whether or not you issue. issue: false changes the status
(DRAFT), leaves issuedAt null, and suppresses the invoice.issued webhook; it
does not withhold a number.
Draft vs issued
Issued (issue: true, the default)
Status ISSUED, issuedAt stamped, and an invoice.issued
webhook is emitted. It is billable: you can mint a payable link, generate the PDF,
and it can settle or be voided.
Draft (issue: false)
Status DRAFT, issuedAt null, no webhook. A draft can be fetched, rendered as a
PDF, or voided — but it cannot be paid (minting a payable link is rejected).
v1 has no endpoint to promote an existing draft to issued, so set issue: true
when you are ready to bill.
Idempotency
POST /invoices is a create-style mutation and accepts an
Idempotency-Key header. A retry with the same key
and same body replays the first response — same invoice, same number — without
running the create again, so a retry never allocates a second number. The
payable-link and void endpoints do not take an idempotency key.
Mint a payable link
POST /invoices/{id}/payable-link raises a payment for the invoice's outstanding
balance and returns the hosted-checkout URL to send the customer to. Behind the
scenes it delegates to the payment use case, so the gateway logic is never
duplicated and the payment is linked back to the invoice (invoiceId).
curl -X POST http://localhost:8080/api/v1/invoices/4012/payable-link \
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"The response is a PayableLinkDto carrying only payableUrl — there is no
reference or checkoutUrl key:
{
"data": { "payableUrl": "http://localhost:3000/pay/pay_7f3a9c2e" },
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:05:00Z" },
"pagination": null
}| Field | Type | Required | Notes |
|---|---|---|---|
payableUrl | string | Yes | The service-hosted checkout URL the customer is routed to for the outstanding amount. |
Two rejections to handle (both 400 VALIDATION_ERROR):
{
"data": null,
"meta": {
"success": false,
"message": "A draft invoice cannot be paid; issue it first",
"errorCode": "VALIDATION_ERROR",
"timestamp": "2026-06-30T12:15:00Z"
},
"pagination": null
}- A draft invoice cannot be paid until it is issued.
- An invoice with no outstanding balance (already
PAID) has nothing to charge.
Public branded view
The customer opens the payable link and lands on the public, branded
payable-invoice page served (unauthenticated) at GET /api/v1/pay/{reference},
keyed by the opaque payment reference. It carries the invoice number, line items,
totals, your branding, and the checkout URL to proceed to — and deliberately no PII.
See the payable invoice page guide for the full
payload.
Branding template
Your invoice PDF and the payable page are branded from a single per-app template
(shared across TEST and LIVE — branding does not differ by mode). Read it with
GET /invoices/template and create/replace it with PUT /invoices/template.
PUT is a full replace, not a merge: any field you omit is cleared. Send the
complete branding each time. There is no custom-CSS field — branding is the
fixed set of fields below.
curl -X PUT http://localhost:8080/api/v1/invoices/template \
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
-H "Content-Type: application/json" \
-d '{
"logoUrl": "https://cdn.example.com/acme/logo.png",
"companyName": "Acme Bangladesh Ltd.",
"companyAddress": "House 12, Road 5, Banani, Dhaka 1213",
"contactDetails": "[email protected] • +880 1700 000000",
"footerNote": "Thank you for your business.",
"accentColor": "#1A73E8"
}'UpsertInvoiceTemplateRequest
| Field | Type | Required | Notes |
|---|---|---|---|
logoUrl | string | No | Max 2048. Omit to clear. |
companyName | string | No | Max 255. |
companyAddress | string | No | Max 1024. |
contactDetails | string | No | Max 512. |
footerNote | string | No | Max 1024. |
accentColor | string | No | Max 16; must match ^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$ (a 3- or 6-digit hex colour, e.g. #1A73E8). |
The response is an InvoiceTemplateDto — the same fields plus createdAt /
updatedAt:
{
"data": {
"logoUrl": "https://cdn.example.com/acme/logo.png",
"companyName": "Acme Bangladesh Ltd.",
"companyAddress": "House 12, Road 5, Banani, Dhaka 1213",
"contactDetails": "[email protected] • +880 1700 000000",
"footerNote": "Thank you for your business.",
"accentColor": "#1A73E8",
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:10:00"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:10:00Z" },
"pagination": null
}Download the PDF
GET /invoices/{id}/pdf streams the branded invoice PDF
(application/pdf, sent as an attachment). It is scoped to your app and mode, so an
unknown or out-of-scope id is a 404.
curl -X GET http://localhost:8080/api/v1/invoices/4012/pdf \
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret" \
-o invoice-INV-000001.pdfInstallments & overdue
An invoice can be settled by multiple payments. Each one that lands advances
amountPaidMinor and recomputes amountDueMinor (totalMinor − amountPaidMinor);
the first partial moves the invoice to PARTIALLY_PAID, and the payment that covers
the balance moves it to PAID. The monetary fields are totalMinor,
amountPaidMinor, and amountDueMinor — there is no paidMinor field.
A payment that would push amountPaidMinor above totalMinor (over-payment) is
rejected with 422 INVALID_OPERATION_STATE. Mint each payable link from the live
outstanding balance.
Overdue is not a webhook-driven event — it is an hourly background sweep.
Once an hour the service marks ISSUED and PARTIALLY_PAID invoices whose
dueDate has passed as OVERDUE (a conditional update, so an invoice that settled
or was voided in the meantime is skipped). A payment landing on an OVERDUE invoice
is accepted and moves it back onto the paid track (PARTIALLY_PAID or PAID).
There is no invoice.overdue webhook — OVERDUE (and DRAFT) are not in the
outbound catalogue. Only invoice.issued, invoice.partially_paid,
invoice.paid, and invoice.voided are delivered. Detect overdue by polling
status / dueDate.
A confirmed refund reverses settled amounts: amountPaidMinor
steps back down, moving the invoice from PAID to PARTIALLY_PAID (some balance
still paid) or back to ISSUED (nothing left paid). A refund can never drive
amountPaidMinor negative.
Void an invoice
POST /invoices/{id}/void cancels an invoice. Only a DRAFT or ISSUED invoice
may be voided — once a payment has settled against it (or it is already PAID,
VOID, or OVERDUE) the action is rejected, so a paid balance can never be silently
discarded. The void is recorded in the append-only audit log and emits an
invoice.voided webhook.
curl -X POST http://localhost:8080/api/v1/invoices/4012/void \
-H "X-Api-Key: oi_test_xxx" -H "X-Api-Secret: your-secret"A successful void returns the full InvoiceDto with status: "VOID":
{
"data": {
"id": 4012,
"number": "INV-000001",
"status": "VOID",
"mode": "TEST",
"applicationId": 42,
"customerReference": "cust_8a2f19",
"totalMinor": 100000,
"amountPaidMinor": 0,
"amountDueMinor": 100000,
"currency": "BDT",
"lineItems": [
{ "id": 9001, "description": "Setup fee", "qty": 1, "unitAmountMinor": 50000, "lineTotalMinor": 50000 },
{ "id": 9002, "description": "Monthly plan","qty": 2, "unitAmountMinor": 25000, "lineTotalMinor": 50000 }
],
"payments": [],
"createdAt": "2026-06-30T12:00:00",
"updatedAt": "2026-06-30T12:20:00"
},
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:20:00Z" },
"pagination": null
}Voiding an invoice that has already been paid is rejected with 422:
{
"data": null,
"meta": {
"success": false,
"message": "Only a draft or issued invoice can be voided; invoice INV-000001 is PAID",
"errorCode": "INVALID_OPERATION_STATE",
"timestamp": "2026-06-30T12:20:00Z"
},
"pagination": null
}Errors at a glance
| Condition | Status | errorCode |
|---|---|---|
Unsupported currency (anything but BDT) | 400 | VALIDATION_ERROR |
| Total not positive | 400 | VALIDATION_ERROR |
Payable link on a DRAFT invoice | 400 | VALIDATION_ERROR |
| Payable link with no outstanding balance | 400 | VALIDATION_ERROR |
| Void a settled / already-void / overdue invoice | 422 | INVALID_OPERATION_STATE |
| Over-payment on settlement | 422 | INVALID_OPERATION_STATE |
| Unknown id, or another app's / mode's invoice | 404 | RESOURCE_NOT_FOUND |
Field-shape failures (missing customerReference, empty lineItems, non-positive
qty, a malformed accentColor) are rejected by validation with 400 before the
use case runs. See responses & errors.
Lifecycle
| Status | Meaning |
|---|---|
DRAFT | Created but not issued; not billable. Can be voided, fetched, or PDF'd. |
ISSUED | Issued with a number; billable. Emits invoice.issued. |
PARTIALLY_PAID | Some — but not all — of the balance has settled. |
PAID | Fully settled. |
VOID | Cancelled before settlement (only from DRAFT/ISSUED). |
OVERDUE | Issued/partially-paid and past dueDate; set by the hourly sweep. |
End-to-end flow
Webhooks
Issuing, partial payment, full payment, and voiding are the four delivered invoice events:
| Event | Fires when |
|---|---|
invoice.issued | An invoice is issued (at create with issue: true, or by a subscription/purchase cycle). |
invoice.partially_paid | An installment settles part of the balance. |
invoice.paid | The invoice is fully settled. |
invoice.voided | The invoice is voided before settlement. |
There is no invoice.overdue event. See webhooks for the
envelope, signing, and delivery semantics.
Related
Accept a payment
The hosted-checkout payment a payable link mints.
Payable invoice page
The public branded view at /pay/{reference}.
Refunds
How a confirmed refund reverses settled amounts.
Webhooks
Invoice events, envelope, and signing.
Money & amounts
Why every amount is integer minor units.
Admin · Invoices
The cross-app invoice console.
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.
Payable invoice page
The public, unauthenticated JSON behind a customer-facing branded invoice page — number, line items, totals, your branding, and the checkout URL to pay at — keyed by the opaque payment reference.