# Invoices

Create, send, void and reconcile invoices over the API — or from the dashboard.

# Invoices

Invoices can be created **in the dashboard** (`/dashboard/invoices/new`) or **over the API**. Every invoice produces a hosted payment page at `https://payments.southbill.com/i/{token}` that anyone with the link can pay — no key needed on the customer side.

Base URL `https://api.southbill.com` · Auth `Authorization: Bearer sk_live_…` · Scopes `invoices:read`, `invoices:write`.

## Endpoints

| Method | Path | Scope | Purpose |
|---|---|---|---|
| `POST` | `/v1/invoices` | `invoices:write` | Create a draft (or send immediately with `auto_send`). |
| `GET` | `/v1/invoices` | `invoices:read` | List invoices — `?status=`, `?customer=`, `?limit=`, `?starting_after=`. |
| `GET` | `/v1/invoices/{id}` | `invoices:read` | Retrieve one invoice incl. `lines`. |
| `POST` | `/v1/invoices/{id}` | `invoices:write` | Update a **draft** invoice. |
| `POST` | `/v1/invoices/{id}/send` | `invoices:write` | Finalize → `open`, mint the public token, email the customer. |
| `POST` | `/v1/invoices/{id}/void` | `invoices:write` | Void an unpaid invoice. |
| `POST` | `/v1/invoices/{id}/mark_paid` | `invoices:write` | Record an out-of-band payment (bank transfer, cash). |
| `GET` | `/v1/invoices/{id}/installments` | `invoices:read` | Instalment plan of a `partial` invoice, ordered by `sequence`. |
| `GET` | `/v1/invoices/{id}/payments` | `invoices:read` | Every payment booked against the invoice, oldest first. |

## Create and send

```bash
curl https://api.southbill.com/v1/invoices \
  -H "Authorization: Bearer $SOUTHBILL_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv_9781" \
  -d '{
    "customer": "cus_9f2c41a0b7e34d9a8c15be22",
    "currency": "eur",
    "due_date": "2026-09-30",
    "memo": "Thanks for your business.",
    "auto_send": true,
    "line_items": [
      { "description": "Implementation, September", "quantity": 1, "unit_amount": 120000, "tax_rate": 20 },
      { "description": "Support retainer", "quantity": 3, "unit_amount": 15000, "tax_rate": 20 }
    ]
  }'
```

```node
const invoice = await fetch("https://api.southbill.com/v1/invoices", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SOUTHBILL_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "inv_9781",
  },
  body: JSON.stringify({
    customer: "cus_9f2c41a0b7e34d9a8c15be22",
    currency: "eur",
    due_date: "2026-09-30",
    auto_send: true,
    line_items: [
      { description: "Implementation, September", quantity: 1, unit_amount: 120000, tax_rate: 20 },
    ],
  }),
}).then((r) => r.json());

console.log(invoice.hosted_invoice_url);
```

```python
import os, requests

invoice = requests.post(
    "https://api.southbill.com/v1/invoices",
    headers={
        "Authorization": f"Bearer {os.environ['SOUTHBILL_SECRET_KEY']}",
        "Idempotency-Key": "inv_9781",
    },
    json={
        "customer": "cus_9f2c41a0b7e34d9a8c15be22",
        "currency": "eur",
        "auto_send": True,
        "line_items": [
            {"description": "Implementation, September", "quantity": 1, "unit_amount": 120000, "tax_rate": 20}
        ],
    },
    timeout=30,
).json()

print(invoice["hosted_invoice_url"])
```

```php
<?php
$ch = curl_init("https://api.southbill.com/v1/invoices");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SOUTHBILL_SECRET_KEY"),
    "Content-Type: application/json",
    "Idempotency-Key: inv_9781",
  ],
  CURLOPT_POSTFIELDS => json_encode([
    "customer"   => "cus_9f2c41a0b7e34d9a8c15be22",
    "currency"   => "eur",
    "auto_send"  => true,
    "line_items" => [[
      "description" => "Implementation, September",
      "quantity"    => 1,
      "unit_amount" => 120000,
      "tax_rate"    => 20,
    ]],
  ]),
]);
$invoice = json_decode(curl_exec($ch), true);
```

Response:

```json
{
  "id": "inv_01J…",
  "object": "invoice",
  "livemode": true,
  "number": "SPK-INV-2026-0184",
  "status": "open",
  "currency": "eur",
  "subtotal": 165000,
  "tax": 33000,
  "total": 198000,
  "customer": "cus_9f2c41a0b7e34d9a8c15be22",
  "customer_email": "jane@example.com",
  "due_date": "2026-09-30",
  "hosted_invoice_url": "https://payments.southbill.com/i/9f14c0…",
  "paid_at": null,
  "created": 1756000000,
  "lines": {
    "object": "list",
    "data": [
      {
        "id": "li_0",
        "object": "invoice_item",
        "description": "Implementation, September",
        "quantity": 1,
        "unit_amount": 120000,
        "tax_rate": 20,
        "amount": 120000
      }
    ]
  }
}
```

## Request fields

| Field | Required | Notes |
|---|---|---|
| `line_items[]` | yes | 1–200 items. Each needs `description` and an integer `unit_amount` in **minor units**; `quantity` > 0, `tax_rate` 0–100. |
| `currency` | yes | ISO 4217, lowercase (`eur`, `gbp`, `usd`). |
| `customer` | yes* | Customer id. *Or* pass `customer_email` directly. |
| `customer_email`, `customer_name`, `customer_company`, `customer_tax_id`, `customer_address` | no | Override the values copied from the customer. |
| `due_date` | no | `YYYY-MM-DD`. |
| `memo`, `notes`, `metadata` | no | Free-form; returned on every read and webhook. |
| `auto_send` | no | `true` finalizes and emails in the same call. |
| `payment_mode` | no | `full` (default), `partial` (instalment plan) or `open` (customer chooses the amount). |
| `installment_count` | for `partial` | 2–6 instalments. Each instalment must stay above the currency minimum. |
| `installment_interval` | for `partial` | `weekly`, `biweekly`, `monthly` or `custom`. |
| `installment_interval_days` | for `custom` | 1–365 days between instalments. |
| `first_installment_due_date` | for `partial` | `YYYY-MM-DD`, due date of instalment 1. |
| `installment_reminders_enabled` | no | Default `true`: emails the customer 3 days before and on each due date. |
| `min_payment_amount`, `max_payment_amount` | no | Bounds per payment in `open` mode, minor units. |
| `allow_overpayment` | no | `open` mode only: accept more than the outstanding amount. |

### Partial payments

A `partial` invoice is settled by several payments. Southbill keeps `amount_paid` and `amount_due`
on the invoice, tracks each instalment separately and moves the invoice through
`open → partially_paid → paid`. Read the plan with `GET /v1/invoices/{id}/installments`:

```json
{
  "object": "list",
  "has_more": false,
  "data": [
    { "id": "…", "sequence": 1, "amount": 66000, "amount_paid": 66000, "currency": "eur", "due_date": "2026-10-01", "status": "paid" },
    { "id": "…", "sequence": 2, "amount": 66000, "amount_paid": 0, "currency": "eur", "due_date": "2026-11-01", "status": "open" }
  ]
}
```

`GET /v1/invoices/{id}/payments` returns each booked payment (`source: "stripe"` for online
payments, `source: "manual"` for payments recorded out of band) with `amount`, `amount_refunded`,
`status` and `paid_at`. A refund on a paid partial invoice moves it back to `partially_paid`.

Amounts are integers in the smallest currency unit — `120000` = **1 200.00 EUR**. Totals are computed server-side from the line items; you cannot set `total` directly.

## Lifecycle

```text
draft ──send──► open ──pays in full──────────────► paid
  │              │
  │              ├──pays part (partial/open)──► partially_paid ──rest paid──► paid
  │              ├──mark_paid──────────────────► paid
  └──void────────┴──void────────────────────────► void
```
A refund on a `paid` invoice with an outstanding balance returns it to `partially_paid`.

| Rule | Behaviour |
|---|---|
| Update | Only `draft` invoices — otherwise `400 invoice_not_draft`. |
| Void | Never on a paid invoice (`400 invoice_paid`); blocked while a payment is in flight (`409 invoice_payment_in_progress`). |
| `mark_paid` | Idempotent — a paid invoice returns the invoice unchanged; a void invoice returns `400 invoice_void`. |
| Numbering | `SPK-INV-YYYY-NNNN`, with a **separate sequence per mode** so test traffic never consumes live numbers. |
| Minimum | Total must be ≥ **2.50** in the invoice currency. |

## Webhooks

| Event | When |
|---|---|
| `invoice.created` | Created over the API. |
| `invoice.sent` | Finalized and emailed to the customer. |
| `invoice.finalized` | `draft` → `open`. |
| `invoice.paid` | Paid in full by the customer or via `mark_paid`. |
| `invoice.payment_succeeded` | A single payment was booked — carries `amount_received`. Fires for every payment, including the last one. |
| `invoice.partially_paid` | The invoice moved to `partially_paid` (part of the total is settled). |
| `invoice.installment.paid` | An instalment of a `partial` invoice is fully settled. |
| `invoice.installment.due` | An instalment is due today. |
| `invoice.installment.overdue` | An instalment passed its due date unpaid. |
| `invoice.payment_failed` | Payment attempt failed. |
| `invoice.voided` | Voided. |

Every one of these is also stored in the [Events](/docs/api/events) log, so you can replay after downtime.

Rate limits: 300 reads/min and 60 writes/min per key. All writes accept `Idempotency-Key`.

