Invoices
An invoice is a record of money owed by a customer. Plugipay generates invoices automatically when a subscription cycle closes, and you can also create them by hand for consulting work, deposits, or anything outside a recurring plan — same object shape, same lifecycle, same detail page.
This page documents the HTTP surface. For the conceptual model and dashboard workflow, see Portal → Invoices. For HMAC signing of every request below, see Authentication.
Lifecycle
Every invoice moves through a small state machine:
| Status | Means | Editable? |
|---|---|---|
draft |
Created but not issued. Customer doesn't see it. | Yes (limited) |
open |
Issued and awaiting payment. Line items locked. | No |
past_due |
open past its dueAt. Dunning may apply. |
No |
paid |
Fully settled. amountDue is zero. |
No |
void |
Cancelled. Excluded from outstanding totals. | No |
uncollectible |
Written off after dunning gave up. | No |
Legal transitions:
draft → openviaPOST /finalize, or by creating directly withstatus: "open".open → paidviaPOST /payfor off-platform money, or by the collection engine when a subscription's stored method succeeds.draft|open|past_due → voidviaPOST /void.
Void, don't delete. There is no
DELETEfor invoices. Voiding preserves the audit trail while making clear no money is expected. To reverse apaidinvoice, issue a refund against the underlying payment.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/invoices |
Create an invoice (draft or open) |
GET |
/v1/invoices/:id |
Retrieve an invoice |
GET |
/v1/invoices |
List invoices |
POST |
/v1/invoices/:id/finalize |
Transition draft → open |
POST |
/v1/invoices/:id/send-email |
Email the invoice as a PDF |
POST |
/v1/invoices/:id/pay |
Mark as paid (off-platform money) |
POST |
/v1/invoices/:id/void |
Void the invoice |
GET |
/v1/invoices/:id/pdf |
Redirect to a signed PDF URL |
All endpoints require the plugipay:invoice:read scope for GET and plugipay:invoice:write for POST.
Create an invoice
POST /v1/invoices
Build a manual invoice line-by-line. The customer must already exist. Lines are locked once you transition out of draft.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
customerId |
string | yes | A cus_* ID. |
currency |
string | yes | ISO 4217 code (IDR, USD, etc.). |
lines |
array | yes | At least one line. See below. |
discount |
integer | no | Flat discount in the smallest currency unit. Defaults to 0. |
tax |
integer | no | Flat tax amount, not a percentage. Defaults to 0. |
dueAt |
ISO 8601 string | null | no | When payment is due. |
status |
"draft" | "open" |
no | Defaults to "draft". |
memo |
string | null | no | Up to 500 chars. Stored as metadata. |
subscriptionId |
string | null | no | Attach to a subscription (rare for manual invoices). |
templateId |
string | null | no | Override the workspace default invoice template. |
Each lines[] entry:
| Field | Type | Required | Notes |
|---|---|---|---|
description |
string | yes | 1–255 chars. |
quantity |
integer | yes | Positive. |
unitAmount |
integer | yes | Smallest currency unit. Zero is allowed (e.g. comped item). |
Totals are computed for you. Don't send subtotal or total. The server computes subtotal = sum(quantity * unitAmount), total = max(0, subtotal - discount + tax).
Response — 201
Returns the full Invoice object.
Errors
| Code | Status | When |
|---|---|---|
VALIDATION_ERROR |
400 | Missing required field, wrong type, empty lines. |
not_found |
404 | customerId doesn't exist in this workspace. |
not_found |
404 | templateId doesn't exist or isn't of kind invoice. |
Code samples
curl -X POST https://api.plugipay.com/v1/invoices \
-H "Authorization: Plugipay-HMAC-SHA256 keyId=$KEY_ID, scope=*, signature=$SIG" \
-H "X-Plugipay-Timestamp: $TS" \
-H "Content-Type: application/json" \
-d '{
"customerId": "cus_01HXxxx",
"currency": "IDR",
"lines": [
{ "description": "Consulting - May 2026", "quantity": 1, "unitAmount": 5000000 }
],
"tax": 550000,
"dueAt": "2026-06-15T00:00:00Z",
"status": "open",
"memo": "PO-2026-042"
}'
// Node — @forjio/plugipay-sdk
const invoice = await plugipay.invoices.create({
customerId: 'cus_01HXxxx',
currency: 'IDR',
lines: [
{ description: 'Consulting - May 2026', quantity: 1, unitAmount: 5_000_000 },
],
tax: 550_000,
dueAt: '2026-06-15T00:00:00Z',
status: 'open',
memo: 'PO-2026-042',
});
# Python — plugipay
invoice = plugipay.invoices.create({
"customerId": "cus_01HXxxx",
"currency": "IDR",
"lines": [
{"description": "Consulting - May 2026", "quantity": 1, "unitAmount": 5_000_000},
],
"tax": 550_000,
"dueAt": "2026-06-15T00:00:00Z",
"status": "open",
"memo": "PO-2026-042",
})
// Go — github.com/hachimi-cat/saas-plugipay/sdk/go
inv, err := plugipay.Invoices.Create(ctx, plugipay.InvoiceCreateInput{
CustomerId: "cus_01HXxxx",
Currency: "IDR",
Lines: []plugipay.InvoiceCreateLine{
{Description: "Consulting - May 2026", Quantity: 1, UnitAmount: 5_000_000},
},
Tax: ptr(550_000),
DueAt: ptr("2026-06-15T00:00:00Z"),
Status: ptr("open"),
Memo: ptr("PO-2026-042"),
})
Retrieve an invoice
GET /v1/invoices/:id
Fetch a single invoice by ID, including all line items.
Response — 200
Returns the full Invoice object.
Errors
| Code | Status | When |
|---|---|---|
not_found |
404 | No invoice with this ID in your workspace. |
curl https://api.plugipay.com/v1/invoices/inv_01HXxxx \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS"
const invoice = await plugipay.invoices.get('inv_01HXxxx');
// Python: plugipay.invoices.get("inv_01HXxxx")
// Go: plugipay.Invoices.Get(ctx, "inv_01HXxxx")
List invoices
GET /v1/invoices
Paginated list, newest first by default.
Query parameters
| Param | Type | Notes |
|---|---|---|
limit |
integer | 1–100, default 20. |
cursor |
string | From meta.page.nextCursor. |
order |
"asc" | "desc" |
Default "desc". |
status |
string | Filter by status: draft, open, past_due, paid, void, uncollectible. |
customerId |
string | Filter by customer. |
subscriptionId |
string | Filter to invoices generated by a specific subscription. |
Response — 200
Paginated envelope (meta.page populated). data is an array of Invoice objects.
curl 'https://api.plugipay.com/v1/invoices?status=open&limit=50' \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS"
const page = await plugipay.invoices.list({ status: 'open', limit: 50 });
for (const inv of page.data) console.log(inv.number, inv.amountDue);
// Python: plugipay.invoices.list(status="open", limit=50)
// Go: plugipay.Invoices.List(ctx, plugipay.InvoiceListParams{Status: ptr("open"), Limit: ptr(50)})
See Pagination for cursor mechanics.
Finalize an invoice
POST /v1/invoices/:id/finalize
Transitions a draft invoice to open, stamping issuedAt. After this the invoice is locked — line items, discount, and tax can no longer change. Idempotent if the invoice is already open; rejected for other statuses.
Request body — empty {}.
Response — 200
Returns the updated Invoice object with status: "open" and issuedAt set.
Errors
| Code | Status | When |
|---|---|---|
not_found |
404 | No invoice with this ID. |
conflict |
409 | Invoice is paid, void, or uncollectible. |
curl -X POST https://api.plugipay.com/v1/invoices/inv_01HXxxx/finalize \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS"
const invoice = await plugipay.invoices.finalize('inv_01HXxxx');
// Python: plugipay.invoices.finalize("inv_01HXxxx")
// Go: plugipay.Invoices.Finalize(ctx, "inv_01HXxxx")
Send an invoice by email
POST /v1/invoices/:id/send-email
Emails the invoice as a PDF attachment, with a public hosted payment link in the body. Resending the same invoice is fine — useful for overdue reminders — and each send is recorded in the audit trail.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | no | Override recipient. Defaults to the customer's email on file. |
Response — 200
{
"data": { "sent": true, "to": "alice@example.com" },
"error": null,
"meta": { "requestId": "...", "timestamp": "..." }
}
Errors
| Code | Status | When |
|---|---|---|
not_found |
404 | No invoice with this ID. |
no_recipient |
409 | No to supplied and customer has no email on file. |
502 |
502 | Mail provider rejected the send. Retry with backoff. |
curl -X POST https://api.plugipay.com/v1/invoices/inv_01HXxxx/send-email \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS" \
-H "Content-Type: application/json" \
-d '{"to":"alice@example.com"}'
await plugipay.invoices.sendEmail('inv_01HXxxx', 'alice@example.com');
// Python: plugipay.invoices.send_email("inv_01HXxxx", to="alice@example.com")
// Go: plugipay.Invoices.SendEmail(ctx, "inv_01HXxxx", ptr("alice@example.com"))
Sender verification. The
fromaddress comes from your workspace's business settings. If the underlying domain isn't verified, mail providers will silently drop the send. Verify the domain under Settings → Business before relying on invoice emails.
Void an invoice
POST /v1/invoices/:id/void
Cancels an invoice. Status becomes void, the invoice drops out of outstanding-balance reporting, and the record is preserved for audit. Idempotency-Key is required.
Request body — empty {}.
Response — 200
Returns the updated Invoice object with status: "void" and voidedAt set.
Errors
| Code | Status | When |
|---|---|---|
not_found |
404 | No invoice with this ID. |
conflict |
409 | Invoice is already void. |
conflict |
409 | Invoice is paid — issue a refund instead. |
curl -X POST https://api.plugipay.com/v1/invoices/inv_01HXxxx/void \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS" \
-H "Idempotency-Key: void-inv_01HXxxx-attempt-1"
const voided = await plugipay.invoices.void('inv_01HXxxx');
// Python: plugipay.invoices.void("inv_01HXxxx")
// Go: plugipay.Invoices.Void(ctx, "inv_01HXxxx")
Mark an invoice as paid
POST /v1/invoices/:id/pay
Records that the invoice was settled outside Plugipay (typically a bank transfer). Status becomes paid, amountPaid is set to the total, amountDue to zero, and a ledger credit is posted. A plugipay.invoice.paid.v1 event fires and an auto-issued Receipt is created and emailed to the customer.
This is a bookkeeping action, not a money movement. Only call it after funds have actually landed.
Idempotency-Key is required.
Request body — empty {}.
Response — 201
Returns the updated Invoice object with status: "paid", paidAt set, and zeroed amountDue.
Errors
| Code | Status | When |
|---|---|---|
not_found |
404 | No invoice with this ID. |
conflict |
409 | Invoice is already paid. |
conflict |
409 | Invoice is void — can't pay a voided invoice. |
curl -X POST https://api.plugipay.com/v1/invoices/inv_01HXxxx/pay \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS" \
-H "Idempotency-Key: mark-paid-inv_01HXxxx"
const paid = await plugipay.invoices.pay('inv_01HXxxx');
// Python: plugipay.invoices.pay("inv_01HXxxx")
// Go: plugipay.Invoices.Pay(ctx, "inv_01HXxxx")
Get the invoice PDF
GET /v1/invoices/:id/pdf
Returns a 302 redirect to a signed, short-lived URL (~5 minutes) that serves the invoice PDF. Every invoice gets a PDF the moment it's created, using the workspace template (or the per-invoice template if you supplied templateId).
curl -L https://api.plugipay.com/v1/invoices/inv_01HXxxx/pdf \
-H "Authorization: Plugipay-HMAC-SHA256 ..." \
-H "X-Plugipay-Timestamp: $TS" \
-o invoice.pdf
For inline bytes without the redirect, GET /v1/invoices/:id/invoice.pdf returns the PDF directly. The HTML version (invoice.html) and ESC/POS thermal-printer payload (invoice.escpos?width=80) are also available — these mirror the dashboard's Share & reprint card.
The Invoice object
{
"id": "inv_01HXxxxxxxxxxxxxxxxxxxxxxx",
"arn": "arn:plugipay:acc_01HX...:invoice:inv_01HX...",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"customerId": "cus_01HXxxxxxxxxxxxxxxxxxxxxxx",
"subscriptionId": null,
"status": "open",
"number": "PLGP-2026-05-0042",
"currency": "IDR",
"subtotal": 5000000,
"discount": 0,
"tax": 550000,
"total": 5550000,
"amountPaid": 0,
"amountDue": 5550000,
"dueAt": "2026-06-15T00:00:00.000Z",
"issuedAt": "2026-05-12T10:42:00.123Z",
"paidAt": null,
"voidedAt": null,
"hostedInvoiceUrl": "https://plugipay.com/c/inv_01HXxxx",
"collectionAttempts": 0,
"lines": [
{
"id": "il_01HXxxxxxxxxxxxxxxxxxxxxxx",
"description": "Consulting - May 2026",
"quantity": 1,
"unitAmount": 5000000,
"amount": 5000000,
"priceId": null,
"metadata": null
}
],
"metadata": { "memo": "PO-2026-042" },
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
}
Fields
| Field | Type | Notes |
|---|---|---|
id |
string | inv_* ULID. |
arn |
string | Full Plugipay ARN for cross-service references. |
accountId |
string | Owning workspace. |
customerId |
string | The billed cus_*. |
subscriptionId |
string | null | Set when generated by a subscription cycle; null for manual invoices. |
status |
enum | draft | open | past_due | paid | void | uncollectible. |
number |
string | Human-friendly invoice number, monotonic per workspace: PLGP-YYYY-MM-NNNN. |
currency |
string | ISO 4217. |
subtotal |
integer | sum(line.amount). |
discount |
integer | Flat discount, smallest currency unit. |
tax |
integer | Flat tax, smallest currency unit (Plugipay takes a final figure, not a percentage). |
total |
integer | max(0, subtotal - discount + tax). |
amountPaid |
integer | What's been collected so far. |
amountDue |
integer | total - amountPaid. Zero on paid. |
dueAt |
ISO 8601 | null | When payment is due. |
issuedAt |
ISO 8601 | null | When status first became open. |
paidAt |
ISO 8601 | null | When status first became paid. |
voidedAt |
ISO 8601 | null | When status first became void. |
hostedInvoiceUrl |
string | null | Customer-facing payment page. Public, no auth. |
collectionAttempts |
integer | Number of automatic charge attempts (subscription invoices only). |
lines |
array | Line items. Always present, length ≥ 1. |
metadata |
object | null | Your custom keys, plus reserved memo. |
createdAt |
ISO 8601 | Record creation timestamp. |
updatedAt |
ISO 8601 | Last state change. |
Each lines[] item:
| Field | Type | Notes |
|---|---|---|
id |
string | il_* ULID. |
description |
string | Up to 255 chars. |
quantity |
integer | Positive. |
unitAmount |
integer | Smallest currency unit. |
amount |
integer | quantity * unitAmount. |
priceId |
string | null | Set for subscription-generated lines that reference a price_*. |
metadata |
object | null | Per-line metadata. |
Plugipay takes a flat tax amount, not a percentage. This keeps issued invoices immutable and accommodates the rounding accountants frequently apply by hand. A 11% PPN on a Rp 5,000,000 subtotal is
tax: 550000.
Events
These webhook events fire from the invoice lifecycle. Each carries the full Invoice object as data.object. See Webhooks for the signature and delivery model.
| Event type | Fires when |
|---|---|
plugipay.invoice.created.v1 |
A draft invoice is created. |
plugipay.invoice.finalized.v1 |
An invoice transitions to open (either via finalize or by being created directly in open). |
plugipay.invoice.paid.v1 |
An invoice transitions to paid — whether by auto-collection, Mark-as-paid, or a hosted payment-page settlement. |
plugipay.invoice.payment_failed.v1 |
An automatic charge attempt against a subscription invoice failed. Includes the failure reason in data.object metadata. |
plugipay.invoice.voided.v1 |
An invoice transitions to void. |
A subscription cycle close emits, in order: created.v1 → finalized.v1, and then either paid.v1 (success) or payment_failed.v1 (which may be followed by another payment_failed.v1 per retry and ultimately paid.v1 or voided.v1).
Notes and edge cases
- Currency is per-invoice. One invoice has one currency; you can't mix IDR and USD lines.
past_dueis automatic. Plugipay flipsopeninvoices oncedueAtpasses; you can't set it explicitly. The invoice is still collectible — the status is informational.- Drafts don't bill. Call
finalizeto issue. - The hosted payment URL is public. Treat it as a bearer-token equivalent.
Next
- Subscriptions — recurring billing that auto-creates invoices.
- Receipts — the proof-of-payment counterpart to a paid invoice.
- Refunds — how to reverse a
paidinvoice. - Webhooks — consume the events above.
- Idempotency — required for
payandvoid.