Skip to content
On this page

Developers › Get paid without a website

Invoicing

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

Verified 6 November 2026Explain this to your team →

View as Markdown

An invoice is a bill you raise against a customer, 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.

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

StatusWhat it means
draftBeing 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.
openFinalized. It has a number from your own consecutive sequence and its totals are frozen. This is the state money is taken in.
paidWhat has been received covers the total. Final.
voidRaised in error. Keeps its number and its lines, so the copy in the customer’s inbox still matches something. Final.
uncollectibleOwed and written off. Not the same as void: the debt was real. Final.
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:

AnswerWhat it meansWhat to do
503 email_not_sentThe 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_rejectedOur 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.
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"}'
LineRateTaxWhy
1 × 100012.5% exclusive125Added on top. The line is 1000 net, 1125 gross.
3 × 333 = 99912.5% exclusive125124.875 rounds half-up to 125, which is what a calculator gives.
1 × 100012.5% inclusive111Backed OUT: 1000 ÷ 1.125 is 889 net, so the tax is 111 — not 125.
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.

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.

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.

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. 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:

WhenWhat it says
3 days before the due dateDue in 3 days.
On the due dateDue today.
3 days late3 days overdue.
7 days late7 days overdue.
14 days late14 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

Beta, and honestly so. POST /v1/invoices/:id/pay charges a card the customer has already saved with you, 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 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.

EventWhen
invoice.createdA draft exists.
invoice.finalizedIt has a number and its totals are fixed.
invoice.sentIt was emailed to the customer. Never fires for a send that failed.
invoice.payment_succeededA payment landed. Fires for every payment, including the settling one and an overpayment.
invoice.paidIt became paid. Fires once, never for a later overpayment.
invoice.voidedCancelled.
invoice.marked_uncollectibleWritten off.
invoice.reminder_sentAn automatic reminder went out.
invoice.updatedA 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.