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 → open via POST /finalize, or by creating directly with status: "open".
  • open → paid via POST /pay for off-platform money, or by the collection engine when a subscription's stored method succeeds.
  • draft|open|past_due → void via POST /void.

Void, don't delete. There is no DELETE for invoices. Voiding preserves the audit trail while making clear no money is expected. To reverse a paid invoice, 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 from address 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.v1finalized.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_due is automatic. Plugipay flips open invoices once dueAt passes; you can't set it explicitly. The invoice is still collectible — the status is informational.
  • Drafts don't bill. Call finalize to 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 paid invoice.
  • Webhooks — consume the events above.
  • Idempotency — required for pay and void.
Plugipay — Payments that don't tax your success