Invoices

An invoice is a billable line-item document tied to a customer. It can be one-off (manual create) or generated automatically by a subscription with collection_method="send_invoice". The Python SDK wraps the seven invoice endpoints behind plugipay.invoices. For the wire format and full lifecycle, see API → Invoices.

Namespace

plug.invoices       # _Invoices

Methods

create

plug.invoices.create(
    *,
    customer_id: str,
    currency: str,
    lines: list[dict[str, Any]],
    discount: int | None = None,
    tax: int | None = None,
    due_at: str | None = None,
    status: str | None = None,
    memo: str | None = None,
) -> Invoice

Creates an invoice in draft status by default. lines is a list of {description, amount, quantity?} dicts — amounts in integer minor units. Pass status="open" to skip drafting and emit immediately. No idempotency key is auto-sent on this call — wrap your own retry if needed via plug.request.

invoice = plug.invoices.create(
    customer_id="cus_01HX...",
    currency="IDR",
    lines=[
        {"description": "Pro subscription, May 2026", "amount": 149_000_00, "quantity": 1},
        {"description": "SMS top-up (1000 msgs)", "amount": 50_000_00, "quantity": 1},
    ],
    tax=21_900_00,             # 11% PPN on subtotal
    due_at="2026-06-12T00:00:00Z",
    memo="Thank you for your business.",
)
print(invoice["id"])           # "inv_01HX..."

get

plug.invoices.get(invoice_id: str) -> Invoice

Retrieves the current state, including computed subtotal, total, and amountDue after payments. Use for reconciliation; subscribe to plugipay.invoice.* webhooks for state transitions in real time.

invoice = plug.invoices.get("inv_01HX...")
print(invoice["status"], invoice["amountDue"])

list

plug.invoices.list(
    *,
    limit: int | None = None,
    cursor: str | None = None,
    status: str | None = None,
    customer_id: str | None = None,
) -> PageResult[Invoice]

Lists invoices. status is "draft" | "open" | "paid" | "void" | "uncollectible".

# All unpaid invoices older than 7 days
from datetime import datetime, timedelta, timezone

cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
overdue = []
cursor = None
while True:
    page = plug.invoices.list(limit=100, status="open", cursor=cursor)
    for inv in page.data:
        if inv.get("dueAt") and inv["dueAt"] < cutoff:
            overdue.append(inv)
    if not page.has_more:
        break
    cursor = page.cursor

finalize

plug.invoices.finalize(invoice_id: str) -> Invoice

Promotes a draft invoice to open and locks the line items. Once finalized, lines and totals are immutable — to correct mistakes, void and re-create. No auto idempotency key.

plug.invoices.finalize("inv_01HX...")

pay

plug.invoices.pay(invoice_id: str) -> Invoice

Charges the customer's default payment token to settle an open invoice. Auto idempotency key. The invoice transitions to paid synchronously on success; on provider-async methods (VA, QRIS) it stays open until the webhook fires.

plug.invoices.pay("inv_01HX...")

void

plug.invoices.void(invoice_id: str) -> Invoice

Marks an open invoice as void — no longer collectible, but kept for audit. Use this for billing mistakes; do not delete (Plugipay doesn't expose delete on purpose). Auto idempotency key.

plug.invoices.void("inv_01HX...")

send_email

plug.invoices.send_email(invoice_id: str, *, to: str | None = None) -> dict

Triggers Plugipay's emailer to send the invoice PDF + hosted-pay link. Without to, sends to the customer's email on file; with to, overrides the recipient (cc/internal copies). Returns a plain dict, not an Invoice — useful fields are {"sent": True, "to": "...", "messageId": "..."}.

plug.invoices.send_email("inv_01HX...")
plug.invoices.send_email("inv_01HX...", to="accounts-payable@buyer.com")

Finalize then pay, in that order. A draft invoice can't be paid. Either pass status="open" on create to skip the draft step, or run finalize before pay. Most subscription-generated invoices arrive as open already.

Types

from plugipay import Invoice, PageResult

Invoice is a Resource subclass. Likely-read fields:

  • inv["id"]inv_ + ULID.
  • inv["status"]"draft" | "open" | "paid" | "void" | "uncollectible".
  • inv["currency"], inv["subtotal"], inv["total"], inv["amountDue"].
  • inv["lines"] — list of line dicts.
  • inv["dueAt"], inv["paidAt"] — ISO 8601 UTC, nullable.
  • inv.get("hostedInvoiceUrl") — buyer-facing hosted page.

Full reference at API → Invoices.

Common patterns

Send-invoice flow for one-off bills.

invoice = plug.invoices.create(
    customer_id=customer["id"],
    currency="IDR",
    lines=[{"description": "Setup fee", "amount": 500_000_00, "quantity": 1}],
    due_at="2026-06-12T00:00:00Z",
)
plug.invoices.finalize(invoice["id"])
plug.invoices.send_email(invoice["id"])

Auto-charge if a default token exists, else email.

def collect(plug, invoice_id):
    inv = plug.invoices.get(invoice_id)
    customer = plug.customers.get(inv["customerId"])
    if customer.get("defaultPaymentTokenId"):
        plug.invoices.pay(invoice_id)
    else:
        plug.invoices.send_email(invoice_id)

Voiding with audit trail. Capture the reason in metadata via plug.request (the SDK kwargs don't expose metadata on void):

plug.request(
    method="POST",
    path=f"/api/v1/invoices/{invoice_id}/void",
    body={"metadata": {"reason": "duplicate billing"}},
    idempotency_key=f"void:{invoice_id}",
)

Reconciliation against your accounting system. Pull paid invoices since last sync:

last_sync = "2026-05-01T00:00:00Z"
cursor = None
while True:
    page = plug.invoices.list(limit=100, status="paid", cursor=cursor)
    for inv in page.data:
        if inv.get("paidAt", "") < last_sync:
            cursor = None
            break
        upsert_in_accounting(inv)
    if not page.has_more or cursor is None:
        break
    cursor = page.cursor

Errors

err.status err.code Cause
400 validation_error Empty lines, negative amounts, bad currency.
404 not_found Invoice id missing or in another workspace.
409 invalid_state_transition pay on a draft, finalize on a paid invoice, void on a paid invoice.
409 idempotency_key_reused Same Idempotency-Key against a different body.
422 payment_method_required pay called but customer has no default token.
422 insufficient_funds Provider rejected the charge — typically a webhook follow-up arrives.

All raise PlugipayError. See Errors.

Next

  • API → Invoices — full field reference, line-item shape, and error catalog.
  • Customers — set default_payment_token_id so pay works without prompting.
  • Refunds — refund a paid invoice.
  • Receipts — what gets generated on payment.
Plugipay — Payments that don't tax your success