Invoicing API
A complete server-side accounts-receivable API. Create customers, draft invoices, post and email them, record payments, issue refunds and credit notes, run recurring templates, and pull a live AR aging report. Every endpoint is multi-tenant, scope-gated, auditable, and idempotency-aware.
Overview
The Invoicing API is the same surface our own dashboard uses. Every action a member can perform in the app, you can perform from your backend with a tenant-scoped API key. Mutations write an immutable audit log row, emit a signed webhook, and increment the per-tenant api_calls_per_month quota for your plan.
This page documents 21 endpoints across customers, invoices, payments, refunds, credit notes, recurring templates, and reporting. For Stripe-hosted Checkout sessions and no-code Payment Links, see the separate Payments API.
Auth & base URL
| Base URL | https://app.softsolz.uk/api/v1/services/invoicing |
|---|---|
| Auth | Authorization: Bearer sk_live_… or sk_test_… |
| Content type | application/json on every mutating request |
| Browser origins | Rejected by default. The v1 API is server-to-server. |
Generate API keys in the SoftSolz app at Developers → API keys. Pick the scopes the key needs (see below) and copy the secret once - it is hashed at rest and can never be revealed again.
Scopes
Every endpoint requires one of these scopes on the calling key. A wildcard service.invoicing.* grants all of them.
| Scope | Allows |
|---|---|
service.invoicing.customers.view | List, read customers. |
service.invoicing.customers.manage | Create, update, archive customers. |
service.invoicing.customer_portal.manage | Issue customer-portal access tokens. |
service.invoicing.invoices.view | List, read invoices, download invoice PDFs. |
service.invoicing.invoices.create | Create + update draft invoices. |
service.invoicing.invoices.post | Post a draft invoice (finalises numbering and locks edits). |
service.invoicing.invoices.send | Email an invoice to the customer. |
service.invoicing.invoices.void | Void a posted invoice. |
service.invoicing.payments.record | Mark an invoice paid with a manual payment. |
service.invoicing.reminders.manage | Send a payment reminder email. |
service.invoicing.recurring.manage | Create and list recurring invoice templates. |
service.invoicing.credits.create | Issue a credit note against an invoice. |
service.invoicing.refunds.create | Refund a payment back to the customer. |
service.invoicing.ar.aging.view | Read the AR aging report. |
Response envelope
Every successful response uses the same envelope so client code can be uniform.
// Single object (GET /resource/:id, POST /resource)
{ "data": { "id": 42, ... } }
// Paginated list (GET /resource?limit=&offset=)
{ "data": [ ... ], "meta": { "limit": 50, "offset": 0, "has_more": false } }
Every response also carries a Softsolz-Request-Id header. Keep it in your logs - support uses it to trace your request through our systems.
Errors
Errors use the same envelope, with HTTP status set to the right 4xx / 5xx code.
{
"error": {
"code": "bad_request",
"message": "amount is required and must be a positive number.",
"details": { ... } // optional, per-endpoint shape
}
}
| Status | Code | When |
|---|---|---|
400 | bad_request | Missing or invalid input. Inspect message. |
401 | - | Missing, revoked, or expired API key. |
402 | service_inactive | Tenant has no active Invoicing subscription. |
403 | - | Key is missing the required scope, or request had a browser Origin. |
404 | request_failed | Resource not found in this tenant. |
409 | request_failed | Conflict - resource is in the wrong state (e.g. void a paid invoice). |
409 | idempotency_key_conflict | Same Idempotency-Key replayed with a different body. |
409 | STALE_VERSION | Optimistic-concurrency clash on a PUT - re-read and retry. |
429 | rate_limited | Per-key rate limit exceeded. Honour Retry-After. |
Idempotency
Every mutating endpoint accepts an optional Idempotency-Key request header. Replay the same key with the same body and you get the original response back; replay it with a different body and you get a 409 idempotency_key_conflict. Keys live for 24 hours.
Idempotency-Key: 11111111-2222-3333-4444-555555555555
Use a UUIDv4 per logical operation. Re-use the same key across network retries; never re-use it across unrelated operations.
Sandbox keys
A key prefixed sk_test_ always routes to your sandbox tenant. Sandbox tenants have their own data, their own subscription state (paid services auto-activate), and never hit real Stripe / email side-effects. Swap to sk_live_ in production with no other code changes.
Customers
List customers
GET /customers?search=&is_active=&limit=&offset= · scope service.invoicing.customers.view
Returns a paginated list. limit defaults to 50, max 200. search matches display_name or email (case-insensitive). is_active accepts true / false.
Get one customer
GET /customers/:id · scope service.invoicing.customers.view
Create a customer
POST /customers · scope service.invoicing.customers.manage · idempotent
| Field | Required | Notes |
|---|---|---|
display_name | Yes | Trimmed. What appears on invoices. |
email | No | Lower-cased on save. Required if you plan to email invoices. |
phone | No | Free text. |
billing_address_line1 .. billing_country | No | billing_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country. |
tax_id | No | VAT / EIN. Free text. |
default_payment_terms_days | No | Integer. Defaults to 30. |
default_currency | No | 3-letter ISO. Defaults to USD. |
notes | No | Internal note. Never shown to the customer. |
curl -X POST https://app.softsolz.uk/api/v1/services/invoicing/customers \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"display_name": "Acme Coyote Ltd",
"email": "ap@acme.example",
"billing_address_line1": "1 Mesa Lane",
"billing_city": "Tucson",
"billing_country": "GB",
"default_currency": "GBP",
"default_payment_terms_days": 14
}'
Request body fields:
| Field | Type | Required | Description |
|---|---|---|---|
display_name | string | required | Customer display name (trimmed). What appears on invoices. |
email | string | null | optional | Primary AR email (lower-cased on save). |
phone | string | null | optional | Contact phone. |
billing_address_line1 | string | null | optional | Billing address line 1. |
billing_city | string | null | optional | Billing city. |
billing_postal_code | string | null | optional | Billing postal code. |
billing_country | string | null | optional | Billing country (ISO 2-letter). |
tax_id | string | null | optional | Tax / VAT id. |
default_payment_terms_days | integer | optional | Default payment terms in days (default 30). |
default_currency | string | optional | 3-letter currency code; defaults to USD. |
notes | string | null | optional | Free-text notes. |
Update a customer
PUT /customers/:id · scope service.invoicing.customers.manage
Send only the fields you want to change. To enable optimistic concurrency, include the expected_updated_at returned by the previous GET; if a teammate edited the row in the meantime you get a 409 STALE_VERSION.
Archive a customer
DELETE /customers/:id · scope service.invoicing.customers.manage
Soft-archive (flips is_active to false). Returns 409 if the customer has open invoices - void or settle them first.
Issue a customer-portal token
POST /customers/:id/portal-token · scope service.invoicing.customer_portal.manage · idempotent
Returns a short-lived token + hosted URL the customer can use to view their invoices and pay outstanding balances without an account.
Invoices
List invoices
GET /invoices?status=&customer_id=&date_from=&date_to=&limit=&offset= · scope service.invoicing.invoices.view
status ∈ draft | sent | viewed | partially_paid | paid | overdue | void | written_off.
Get one invoice
GET /invoices/:id · scope service.invoicing.invoices.view
Returns the invoice header, every line, payment history, and status-change history. The payments[] array carries the id values you need when issuing a refund.
Create a draft invoice
POST /invoices · scope service.invoicing.invoices.create · idempotent
| Field | Required | Notes |
|---|---|---|
customer_id | Yes | Integer. Must belong to your tenant and be active. |
currency | No | 3-letter ISO. Defaults to the customer's default_currency. |
issue_date | No | YYYY-MM-DD. Defaults to today. |
due_date | No | YYYY-MM-DD. Defaults to issue_date + customer.default_payment_terms_days. |
po_number | No | Free text. Empty string clears the auto-assigned PO number. |
lines | Yes | Array of at least one { description, quantity, unit_price, tax_rate?, account_code? }. |
discount | No | Number. Subtracted from subtotal before tax. |
shipping_amount | No | Number ≥ 0. Added before tax. |
header_tax_rate_pct | No | Number ≥ 0. Overrides per-line tax if set. |
notes / terms | No | Free text. Both shown to the customer on the PDF. |
payment_method | No | "stripe" | "bank_transfer". Default chosen automatically based on whether Stripe Connect is set up. |
bank_details | If bank_transfer | Multi-line text the customer sees on the PDF. Required when payment_method = "bank_transfer". |
show_bank_details | No | Boolean, defaults true. Toggles the bank-details block on the PDF. |
curl -X POST https://app.softsolz.uk/api/v1/services/invoicing/invoices \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"customer_id": 42,
"currency": "GBP",
"due_date": "2026-06-30",
"payment_method": "bank_transfer",
"bank_details": "Bank: Test Bank\nAccount: 12345678\nSort: 12-34-56",
"lines": [
{ "description": "Consulting", "quantity": 2, "unit_price": 150, "tax_rate": 0.2 },
{ "description": "Setup fee", "quantity": 1, "unit_price": 50, "tax_rate": 0 }
]
}'
Request body fields:
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | required | Active customer id in this tenant. |
lines | array | required | Line items (at least one) of { description, quantity, unit_price, tax_rate?, account_code? }. The key line_items is accepted as an alias for lines. |
issue_date | string (date) | optional | Issue date (YYYY-MM-DD); defaults to today (UTC). |
due_date | string (date) | optional | Due date (YYYY-MM-DD); defaults to issue_date + customer terms. |
currency | string | optional | 3-letter currency; defaults to the customer currency or USD. |
discount | number | optional | Flat discount in major currency units (e.g. 10.00) applied after tax (default 0). |
shipping_amount | number | optional | Shipping in major currency units (e.g. 5.00) added after tax+discount (default 0). |
header_tax_rate_pct | number | null | optional | When set, total tax = subtotal * pct/100 and per-line tax is ignored. |
po_number | string | null | optional | PO number; omit to auto-allocate per customer. |
notes | string | null | optional | Notes shown on the invoice. |
terms | string | null | optional | Payment terms text. |
payment_method | string | null | optional | Payment path; "stripe" makes the invoice online-payable. |
Line item fields (each entry in lines): description (string, required), quantity (number > 0, required), unit_price (number in major currency units, e.g. 150.00, required), tax_rate (number, line tax percent where 20 = 20%; ignored when header_tax_rate_pct is set), account_code (string, defaults to "sales").
Update a draft invoice
PUT /invoices/:id · scope service.invoicing.invoices.create
Allowed only while status = 'draft'. Send the full lines array to replace all line items, or omit it to keep them. Supports expected_updated_at for optimistic concurrency.
Download invoice PDF
GET /invoices/:id/pdf · scope service.invoicing.invoices.view
Returns either a binary PDF stream (Content-Type: application/pdf) or a JSON { "url": "https://…" } pre-signed link when the PDF is cached in object storage.
Invoice lifecycle
draft ──post──▶ sent / viewed ──mark-paid──▶ partially_paid ──▶ paid
│ │
├──remind──▶ same status │
├──refund──▶ partially_paid ◀──────────────────-─┘
└──void──▶ void (also from draft after post)
Post a draft invoice
POST /invoices/:id/post · scope service.invoicing.invoices.post · idempotent
Finalises the invoice number, locks line edits, and makes it eligible for sending and payment. May route through an approval policy if your tenant has one configured; in that case the response carries { pending_approval: true, instance: { id, … } } and the invoice remains in draft until the approval is decided.
Email the invoice
POST /invoices/:id/send · scope service.invoicing.invoices.send · idempotent
Sends the invoice email with the PDF attached (and the hosted-pay link if Stripe is set up). Status flips to sent.
Send a payment reminder
POST /invoices/:id/remind · scope service.invoicing.reminders.manage · idempotent
Sends a follow-up email. Status is unchanged.
Void the invoice
POST /invoices/:id/void · scope service.invoicing.invoices.void · idempotent
Body: { "reason": "Customer cancelled" } (optional). Voids any payment allocations. May route through an approval policy if configured.
Record a payment
POST /invoices/:id/mark-paid · scope service.invoicing.payments.record · idempotent
| Field | Required | Notes |
|---|---|---|
amount | Yes | Number > 0. May be a partial payment. |
method | No | One of manual, stripe, ach, wire, check, other. bank_transfer is accepted as an alias for wire. Defaults to manual. |
reference | No | External transaction reference - free text. |
paid_at | No | ISO timestamp. Defaults to now. |
Response carries the new payment id - keep it if you plan to refund it later:
{ "data": { "paymentId": 7843, "invoiceId": 921, "finalStatus": "partially_paid" } }
Posts a partial payment if amount < outstanding balance, full payment if equal, and rejects (400) if it exceeds the outstanding by more than a rounding cent. Posting also fires invoicing.invoice.paid on the first transition into paid.
Request body fields:
| Field | Type | Required | Description |
|---|---|---|---|
amount | number | required | Payment amount in major currency units (e.g. 120.00), > 0. Cannot exceed the outstanding balance. |
method | string | optional | One of stripe, manual, ach, wire, check, other (default "manual"). The alias "bank_transfer" maps to "wire". |
reference | string | null | optional | External payment reference (e.g. cheque or transfer id); stored on the payment record. |
received_at | string (date-time) | null | optional | When payment was received (aliases: paid_at, payment_date). Defaults to now. |
Refunds
POST /refunds · scope service.invoicing.refunds.create · idempotent
| Field | Required | Notes |
|---|---|---|
invoice_id | Yes | Integer. |
customer_payment_id | Yes | The paymentId returned by mark-paid (or visible under payments[] on the invoice). |
amount | Yes | Number > 0. Cannot exceed what was allocated minus prior refunds on this payment. |
method | No | Defaults to manual. |
reason | No | Free text, surfaced on the audit log. |
Shrinks amount_paid, rolls invoice status back (paid → partially_paid → sent / overdue / draft), and fires invoicing.refund.issued. May route through an approval policy.
Credit notes
POST /credit-notes · scope service.invoicing.credits.create · idempotent
| Field | Required | Notes |
|---|---|---|
invoice_id | Yes | Must be posted (not draft). |
amount | Yes | Number > 0. Cannot exceed the invoice's outstanding balance. |
reason | No | Free text, shown on the credit-note PDF. |
Allocates a CN-#### credit-note number, applies the amount against amount_paid, and emits invoicing.credit_note.issued.
Recurring invoice templates
List templates
GET /recurring · scope service.invoicing.recurring.manage
Create a template
POST /recurring · scope service.invoicing.recurring.manage
| Field | Required | Notes |
|---|---|---|
name | Yes | Internal label. Shown in the dashboard, never to the customer. |
source_invoice_ids | Yes | Array of one or more posted invoice ids that seed the template's header + lines. |
cadence | No | weekly | monthly (default) | quarterly | yearly | custom. |
cron_expression | If cadence = custom | Standard 5-field cron. |
due_in_days | No | Integer ≥ 0. Defaults to 30. |
start_at | No | ISO timestamp. Defaults to the next cadence tick. |
is_active | No | Boolean, defaults true. |
The scheduler generates a new invoice from the template at every cadence tick. Generated invoices appear under the customer's invoice list and trigger the usual create/post/sent webhooks.
AR aging report
GET /reports/ar-aging?as_of=YYYY-MM-DD · scope service.invoicing.ar.aging.view
Buckets every outstanding invoice into current, 1-30, 31-60, 61-90, 90+ based on days past due as of the supplied date (defaults to today). Returns per-customer and per-tenant totals.
Webhook events
Register a webhook endpoint in Developers → Webhooks and subscribe to any of these events. Payloads are signed with HMAC-SHA256 - see Webhooks for the signing scheme and retry policy.
| Event | Fires when |
|---|---|
invoicing.customer.created | A customer is created (UI or API). |
invoicing.customer.updated | A customer is updated. |
invoicing.customer.archived | A customer is archived. |
invoicing.invoice.created | A draft invoice is created. |
invoicing.invoice.updated | A draft invoice is updated. |
invoicing.invoice.posted | An invoice is posted (draft → sent). |
invoicing.invoice.sent | An invoice is emailed to the customer. |
invoicing.invoice.voided | An invoice is voided. |
invoicing.invoice.paid | An invoice's status flips to paid (first time only). |
invoicing.invoice.overdue | The nightly sweep flips an invoice past its due date. |
invoicing.invoice.reminder_sent | A payment reminder email is sent. |
invoicing.recurring.created | A recurring invoice template is created. |
invoicing.credit_note.issued | A credit note is issued. |
invoicing.refund.issued | A refund is issued. |
invoicing.approval.requested | A post / refund / void enters an approval policy. |
invoicing.approval.approved | An approval step is approved. |
invoicing.approval.rejected | An approval step is rejected. |
invoicing.approval.escalated | An approval is escalated. |
invoicing.approval.expired | An approval SLA expires. |
sk_test_* key into the Invoicing Playground - it walks through customer → invoice → post → mark-paid → refund in your sandbox tenant.