OI Payments Docs
Guides

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

FieldTypeRequiredNotes
customerReferencestringYesMax 255. Your own customer id; find-or-creates the customer record for this app.
customerEmailstringNoValid email, max 320. Persisted only when the customer reference is first created — it does not overwrite an existing contact.
customerPhonestringNoMax 32. Persisted only when the customer reference is first created.
currencystringNoISO-4217, max 3. Defaults to BDT; any other code is rejected with 400.
dueDatestring (ISO date)Noe.g. 2026-07-15. Drives the overdue sweep; no due date means the invoice never goes overdue.
issuebooleanNoDefaults to true. true issues immediately (status ISSUED, emits invoice.issued); false keeps it a DRAFT.
lineItemsarrayYesAt least one line item.
lineItems[].descriptionstringYesNon-blank, max 512.
lineItems[].qtyintegerYesA positive whole number (> 0).
lineItems[].unitAmountMinorinteger (minor units)YesZero or a positive number of minor units (>= 0). The line and invoice total must still come out positive.
metadataobjectNoOptional 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
}
FieldTypeRequiredNotes
idnumberYesInternal invoice id (used in the /{id}/… sub-routes).
numberstringYesPer-app invoice number, e.g. INV-000001 (assigned at creation — see below).
statusstringYesOne of DRAFT, ISSUED, PARTIALLY_PAID, PAID, VOID, OVERDUE.
modestringYesTEST or LIVE — derived from the authenticating credential.
applicationIdnumberYesOwning app id.
customerReferencestringYesYour external customer id this invoice bills.
totalMinorinteger (minor units)YesSum of lineTotalMinor across the lines.
amountPaidMinorinteger (minor units)YesRunning total settled by payments; starts at 0.
amountDueMinorinteger (minor units)YesOutstanding balance = totalMinor − amountPaidMinor.
currencystringYesAlways BDT in v1.
dueDatestring (ISO date)NoOmitted when none was supplied.
issuedAtstring (date-time)NoWhen it was issued; null/omitted while a DRAFT.
lineItems[]arrayYesThe billed lines (see below).
payments[]arrayYesThe payment(s) settling this invoice; empty until a payable link is paid.
metadataobjectNoThe JSON object you attached at creation, echoed back unchanged; omitted when none.
createdAt / updatedAtstring (date-time)YesAudit 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

FieldTypeRequiredNotes
idnumberYesLine item id.
descriptionstringYesThe billed item.
qtyintegerYesQuantity.
unitAmountMinorinteger (minor units)YesPrice per unit.
lineTotalMinorinteger (minor units)YesunitAmountMinor × 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.

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
}
FieldTypeRequiredNotes
payableUrlstringYesThe 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

FieldTypeRequiredNotes
logoUrlstringNoMax 2048. Omit to clear.
companyNamestringNoMax 255.
companyAddressstringNoMax 1024.
contactDetailsstringNoMax 512.
footerNotestringNoMax 1024.
accentColorstringNoMax 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.pdf

Installments & 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 webhookOVERDUE (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

ConditionStatuserrorCode
Unsupported currency (anything but BDT)400VALIDATION_ERROR
Total not positive400VALIDATION_ERROR
Payable link on a DRAFT invoice400VALIDATION_ERROR
Payable link with no outstanding balance400VALIDATION_ERROR
Void a settled / already-void / overdue invoice422INVALID_OPERATION_STATE
Over-payment on settlement422INVALID_OPERATION_STATE
Unknown id, or another app's / mode's invoice404RESOURCE_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

StatusMeaning
DRAFTCreated but not issued; not billable. Can be voided, fetched, or PDF'd.
ISSUEDIssued with a number; billable. Emits invoice.issued.
PARTIALLY_PAIDSome — but not all — of the balance has settled.
PAIDFully settled.
VOIDCancelled before settlement (only from DRAFT/ISSUED).
OVERDUEIssued/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:

EventFires when
invoice.issuedAn invoice is issued (at create with issue: true, or by a subscription/purchase cycle).
invoice.partially_paidAn installment settles part of the balance.
invoice.paidThe invoice is fully settled.
invoice.voidedThe invoice is voided before settlement.

There is no invoice.overdue event. See webhooks for the envelope, signing, and delivery semantics.

On this page