---
id: docs.invoicing
title: "Invoicing"
canonical: https://docs.secureprocessing.app/invoicing
last_verified: 2026-11-06
---

# Invoicing

Bill a customer, send them a link, and let us chase it.

An invoice is a bill you raise against a [customer](https://docs.secureprocessing.app/accept-a-payment.md), with a number, a due date and a hosted page where they pay by card. You build it as a draft, finalize it when it is right, and send it. If it goes unpaid we email reminders on a schedule until it is paid, voided or written off.

> **Note:** **Every amount is an integer in minor units.** A `total` of `135000` is $1,350.00. The one exception is a tax rate’s `percentage`, which is a percentage and not an amount: `12.5` means 12.5%.

## The lifecycle

| Status | What it means |
|---|---|
| `draft` | Being written. No number, nothing owed, invisible to the customer. Lines can be added, edited and removed; the totals are computed from them on every read. |
| `open` | Finalized. It has a number from your own consecutive sequence and its totals are frozen. This is the state money is taken in. |
| `paid` | What has been received covers the total. Final. |
| `void` | Raised in error. Keeps its number and its lines, so the copy in the customer’s inbox still matches something. Final. |
| `uncollectible` | Owed and written off. Not the same as void: the debt was real. Final. |

> **Warning:** **Finalizing cannot be undone, and nothing is ever deleted.** Once an invoice has a number, its lines and its amounts are fixed — we refuse to change them, in the API and in the database. Fix a mistake by voiding it and raising a new one. `DELETE /v1/invoices/:id` exists so that code written against Stripe does not break, but it **voids** the draft rather than deleting it and the response says `"status": "void"`. A bill that was raised stays on the record, so “was this customer ever invoiced?” is always answerable.

## Create, add lines, finalize, send

```bash
# 1. A draft. Its money comes from the lines you add next, not from this call.
curl https://api.secureprocessing.app/v1/invoices \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
        "customer": "cus_9aB1cD3eF5gH7iJ9kL1mN3oP",
        "currency": "usd",
        "days_until_due": 14,
        "memo": "Thanks for diving with us."
      }'

# 2. A line. Quote a price, or write the line yourself in minor units.
curl https://api.secureprocessing.app/v1/invoice_items \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
        "invoice": "in_1aB3cD5eF7gH9jK1lM3nP5qR",
        "description": "Blue Hole Two-Tank Dive",
        "unit_amount": 50000,
        "quantity": 2,
        "tax_rate": "txr_5jK7lM9nP1qR3sT5uV7wX9yZ"
      }'

# 3. Finalize. Assigns the number, freezes the totals, and moves it to open.
curl -X POST https://api.secureprocessing.app/v1/invoices/in_.../finalize \
  -H "Authorization: Bearer sk_test_..."

# 4. Send. Emails the customer a link to the hosted page. A 503 or 400 means it did not go.
curl -X POST https://api.secureprocessing.app/v1/invoices/in_.../send \
  -H "Authorization: Bearer sk_test_..."
```

```ts
const invoice = await sp.invoices.create({
  customer: customer.id,
  currency: "usd",
  days_until_due: 14,
});

await sp.invoiceItems.create({
  invoice: invoice.id,
  description: "Blue Hole Two-Tank Dive",
  unit_amount: 50000, // minor units — $500.00
  quantity: 2,
  tax_rate: gst.id,
});

const open = await sp.invoices.finalize(invoice.id);
console.log(open.number);              // "INV-0007"
console.log(open.total);               // 112500  — minor units, $1,125.00
console.log(open.hosted_invoice_url);  // send the customer here

await sp.invoices.send(invoice.id);
```

`days_until_due` is counted from the moment you **finalize**, not from when you created the draft — a draft that sat for a fortnight is not already overdue the day you send it. Give `due_date` instead if you want a fixed date. Not both.

`send` answers `200` only once the email has gone to our email provider. That is when `sent_at` is set and `invoice.sent` fires. If the email did not go, `sent_at` stays as it was, no event fires, and the answer is one of these two:

| Answer | What it means | What to do |
|---|---|---|
| 503 email_not_sent | The email could not be sent right now. It may not have reached the customer. | Retry. The same Idempotency-Key is fine: a 503 is never stored against it, and the SDK retries it for you. |
| 400 email_recipient_rejected | Our email provider refused the customer's address. param is customer.email. | Retrying will not help, and the 400 is stored against your Idempotency-Key. Send the customer the hosted_invoice_url another way. |

> **Note:** In test mode we only email your own team, with `[TEST]` in the subject, and the same goes for reminders and receipts. If the customer's address is not a member of your team, nothing is sent. The answer is still a `200`, with `test_email_suppressed: true`, `sent_at` stays as it was and no event fires. `sent_at` may already hold an earlier send's time, so read `test_email_suppressed` to know what that request did. To see the email and `invoice.sent`, put a team member's address on the test customer.
>
> A failed send changes nothing else. The invoice is still open, and the customer can still pay at its `hosted_invoice_url`, which you can send them yourself. Within 24 hours, a retry with the same `Idempotency-Key` is built not to email the customer twice: when an earlier attempt may have reached them and only the answer was lost, the retry reuses our email provider’s idempotency key for it. A new key is a new email.

## Invoice numbers

Numbers come from a counter of your own, per account and per mode, and they are **consecutive with no gaps** — `INV-0001`, `INV-0002`, and so on. Five finalizes at the same instant get five consecutive numbers. A finalize that fails puts its number back rather than burning it, which is why we do not use a database sequence: a gap is a question an auditor asks, and “a transaction rolled back” has never been an answer anybody accepts.

Test mode has its own counter, so rehearsing does not advance your real numbering.

## Tax

A tax rate is a row you own. Belize GST — 12.5%, added on top of the line — is created for you when your account is. Put a rate on a line and the tax is computed **per line**, rounded half-up to the minor unit, and added into the invoice’s `tax`.

```bash
curl https://api.secureprocessing.app/v1/tax_rates \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"display_name": "GST", "percentage": 12.5, "inclusive": false, "jurisdiction": "Belize"}'
```

| Line | Rate | Tax | Why |
|---|---|---|---|
| 1 × 1000 | 12.5% exclusive | 125 | Added on top. The line is 1000 net, 1125 gross. |
| 3 × 333 = 999 | 12.5% exclusive | 125 | 124.875 rounds half-up to 125, which is what a calculator gives. |
| 1 × 1000 | 12.5% inclusive | 111 | Backed OUT: 1000 ÷ 1.125 is 889 net, so the tax is 111 — not 125. |

> **Warning:** **A rate’s percentage and inclusivity cannot be changed.** A finalized invoice names the rate that produced the tax printed on it; a rate that moved would make the two disagree on a document your customer is already holding. Create a new rate and set `active: false` on the old one — the same rule, for the same reason, as [a price’s amount](https://docs.secureprocessing.app/products-and-prices.md).

## The hosted page

`hosted_invoice_url` is where you send the customer. It shows the line items, the totals, anything you put in `memo` and `footer`, and a button that takes them to a secure page hosted by the bank. You never see a card number and neither do we.

> **Warning:** **The token in that URL is not the invoice’s id.** The `in_` id goes in API responses, webhook bodies and your own logs; the hosted URL opens a page naming your customer and what they owe. They are deliberately different strings so the id is safe to paste into a support ticket. Treat the URL as the credential it is.

`invoice_pdf` is the same document as an A4 PDF, generated on request. It needs your API key, so it is for your records and your emails rather than something to link a customer to.

## Partial payments

Set `allow_partial: true` and the hosted page shows an amount box. The customer can pay any part of what is outstanding; the balance stays owed and keeps being chased. Each payment fires `invoice.payment_succeeded`, and the one that settles the bill also fires `invoice.paid`, once, alongside its own `invoice.payment_succeeded`.

> **Note:** **An invoice can be overpaid, and we record it rather than hiding it.** A customer with the page open in two tabs can pay twice: both payments reach the bank before either reaches us, so refusing the second is not something we are able to do. `amount_paid` then exceeds `total` and `amount_due` goes negative. Refund the difference — the dashboard flags it, and `POST /v1/invoices/:id/refund_overpayment` refunds it from the most recent payment. Like any partial refund, one made before the bank has settled that payment is refused with `400 refund_not_yet_possible`; see [refunds](https://docs.secureprocessing.app/refunds.md). The overpaying payment fires `invoice.payment_succeeded` and nothing else: the invoice was already paid, so `invoice.paid` does not fire again.

## Reminders

An open invoice with a due date is chased automatically, at most one email a day, on this schedule:

| When | What it says |
|---|---|
| 3 days before the due date | Due in 3 days. |
| On the due date | Due today. |
| 3 days late | 3 days overdue. |
| 7 days late | 7 days overdue. |
| 14 days late | 14 days overdue. The last one. |

Reminders stop the moment the invoice is paid, voided or marked uncollectible, and they stop for good after the last one — an invoice still unpaid a fortnight after it was due needs a person, not a sixth email. An invoice you finalize the day before it is due does not get the earlier reminders retroactively; it starts at the stage that actually applies. Each one fires `invoice.reminder_sent`.

## Charging a saved card

> **Warning:** **Beta, and honestly so.** `POST /v1/invoices/:id/pay` charges a card the customer has already [saved with you](https://docs.secureprocessing.app/save-a-card.md), with nobody present. It works in the bank’s test environment, but the bank has not yet confirmed it for our account, and a merchant-initiated charge has never been proved in production. Treat it as unproven until we say otherwise.

```ts
await sp.invoices.pay(invoice.id, { payment_method: "pm_2xY4zA6bC8dE0fG2hI4jK6lM" });
```

There is no way to charge a card the customer has not already saved. Send the invoice and let them pay on the hosted page — which is what `send` is for.

A `202` — an invoice whose `amount_paid` has not moved — means the charge was sent and the bank’s answer has not reached us yet. The card may already have been charged, so do not charge it again: the payment settles on its own within minutes, and `invoice.payment_succeeded` follows if it went through — with `invoice.paid` alongside it if that payment settled the invoice.

## Events

Subscribe on the [webhooks](https://docs.secureprocessing.app/webhooks.md) page. The one to fulfil on is `invoice.paid`; the one to reconcile a bank statement against is `invoice.payment_succeeded`, which fires once for every payment. Fulfil idempotently, keyed on the invoice id: delivery is at-least-once, so the same event can reach you more than once.

These match Stripe. `invoice.payment_succeeded` fires for every payment attached to the invoice, including the one that settles it and any overpayment after it. `invoice.paid` fires exactly once, when the invoice changes to `paid`, and never for a later overpayment. A payment that settles the invoice therefore fires both, alongside each other, each carrying the invoice as it stood after that payment. They share the same `created` and no order between them is guaranteed — in the events list or in delivery — so do not make one handler wait for the other.

| Event | When |
|---|---|
| invoice.created | A draft exists. |
| invoice.finalized | It has a number and its totals are fixed. |
| invoice.sent | It was emailed to the customer. Never fires for a send that failed. |
| invoice.payment_succeeded | A payment landed. Fires for every payment, including the settling one and an overpayment. |
| invoice.paid | It became paid. Fires once, never for a later overpayment. |
| invoice.voided | Cancelled. |
| invoice.marked_uncollectible | Written off. |
| invoice.reminder_sent | An automatic reminder went out. |
| invoice.updated | A draft’s details were edited with `POST /v1/invoices/:id`, or an open invoice’s memo, footer or metadata changed. Adding or editing a line does not fire it. |
