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 URLhttps://app.softsolz.uk/api/v1/services/invoicing
AuthAuthorization: Bearer sk_live_… or sk_test_…
Content typeapplication/json on every mutating request
Browser originsRejected 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.

ScopeAllows
service.invoicing.customers.viewList, read customers.
service.invoicing.customers.manageCreate, update, archive customers.
service.invoicing.customer_portal.manageIssue customer-portal access tokens.
service.invoicing.invoices.viewList, read invoices, download invoice PDFs.
service.invoicing.invoices.createCreate + update draft invoices.
service.invoicing.invoices.postPost a draft invoice (finalises numbering and locks edits).
service.invoicing.invoices.sendEmail an invoice to the customer.
service.invoicing.invoices.voidVoid a posted invoice.
service.invoicing.payments.recordMark an invoice paid with a manual payment.
service.invoicing.reminders.manageSend a payment reminder email.
service.invoicing.recurring.manageCreate and list recurring invoice templates.
service.invoicing.credits.createIssue a credit note against an invoice.
service.invoicing.refunds.createRefund a payment back to the customer.
service.invoicing.ar.aging.viewRead 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
  }
}
StatusCodeWhen
400bad_requestMissing or invalid input. Inspect message.
401-Missing, revoked, or expired API key.
402service_inactiveTenant has no active Invoicing subscription.
403-Key is missing the required scope, or request had a browser Origin.
404request_failedResource not found in this tenant.
409request_failedConflict - resource is in the wrong state (e.g. void a paid invoice).
409idempotency_key_conflictSame Idempotency-Key replayed with a different body.
409STALE_VERSIONOptimistic-concurrency clash on a PUT - re-read and retry.
429rate_limitedPer-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

FieldRequiredNotes
display_nameYesTrimmed. What appears on invoices.
emailNoLower-cased on save. Required if you plan to email invoices.
phoneNoFree text.
billing_address_line1 .. billing_countryNobilling_address_line1, billing_address_line2, billing_city, billing_state, billing_postal_code, billing_country.
tax_idNoVAT / EIN. Free text.
default_payment_terms_daysNoInteger. Defaults to 30.
default_currencyNo3-letter ISO. Defaults to USD.
notesNoInternal 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:

FieldTypeRequiredDescription
display_namestringrequiredCustomer display name (trimmed). What appears on invoices.
emailstring | nulloptionalPrimary AR email (lower-cased on save).
phonestring | nulloptionalContact phone.
billing_address_line1string | nulloptionalBilling address line 1.
billing_citystring | nulloptionalBilling city.
billing_postal_codestring | nulloptionalBilling postal code.
billing_countrystring | nulloptionalBilling country (ISO 2-letter).
tax_idstring | nulloptionalTax / VAT id.
default_payment_terms_daysintegeroptionalDefault payment terms in days (default 30).
default_currencystringoptional3-letter currency code; defaults to USD.
notesstring | nulloptionalFree-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

statusdraft | 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

FieldRequiredNotes
customer_idYesInteger. Must belong to your tenant and be active.
currencyNo3-letter ISO. Defaults to the customer's default_currency.
issue_dateNoYYYY-MM-DD. Defaults to today.
due_dateNoYYYY-MM-DD. Defaults to issue_date + customer.default_payment_terms_days.
po_numberNoFree text. Empty string clears the auto-assigned PO number.
linesYesArray of at least one { description, quantity, unit_price, tax_rate?, account_code? }.
discountNoNumber. Subtracted from subtotal before tax.
shipping_amountNoNumber ≥ 0. Added before tax.
header_tax_rate_pctNoNumber ≥ 0. Overrides per-line tax if set.
notes / termsNoFree text. Both shown to the customer on the PDF.
payment_methodNo"stripe" | "bank_transfer". Default chosen automatically based on whether Stripe Connect is set up.
bank_detailsIf bank_transferMulti-line text the customer sees on the PDF. Required when payment_method = "bank_transfer".
show_bank_detailsNoBoolean, 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:

FieldTypeRequiredDescription
customer_idintegerrequiredActive customer id in this tenant.
linesarrayrequiredLine 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_datestring (date)optionalIssue date (YYYY-MM-DD); defaults to today (UTC).
due_datestring (date)optionalDue date (YYYY-MM-DD); defaults to issue_date + customer terms.
currencystringoptional3-letter currency; defaults to the customer currency or USD.
discountnumberoptionalFlat discount in major currency units (e.g. 10.00) applied after tax (default 0).
shipping_amountnumberoptionalShipping in major currency units (e.g. 5.00) added after tax+discount (default 0).
header_tax_rate_pctnumber | nulloptionalWhen set, total tax = subtotal * pct/100 and per-line tax is ignored.
po_numberstring | nulloptionalPO number; omit to auto-allocate per customer.
notesstring | nulloptionalNotes shown on the invoice.
termsstring | nulloptionalPayment terms text.
payment_methodstring | nulloptionalPayment 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

FieldRequiredNotes
amountYesNumber > 0. May be a partial payment.
methodNoOne of manual, stripe, ach, wire, check, other. bank_transfer is accepted as an alias for wire. Defaults to manual.
referenceNoExternal transaction reference - free text.
paid_atNoISO 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:

FieldTypeRequiredDescription
amountnumberrequiredPayment amount in major currency units (e.g. 120.00), > 0. Cannot exceed the outstanding balance.
methodstringoptionalOne of stripe, manual, ach, wire, check, other (default "manual"). The alias "bank_transfer" maps to "wire".
referencestring | nulloptionalExternal payment reference (e.g. cheque or transfer id); stored on the payment record.
received_atstring (date-time) | nulloptionalWhen payment was received (aliases: paid_at, payment_date). Defaults to now.

Refunds

POST /refunds · scope service.invoicing.refunds.create · idempotent

FieldRequiredNotes
invoice_idYesInteger.
customer_payment_idYesThe paymentId returned by mark-paid (or visible under payments[] on the invoice).
amountYesNumber > 0. Cannot exceed what was allocated minus prior refunds on this payment.
methodNoDefaults to manual.
reasonNoFree 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

FieldRequiredNotes
invoice_idYesMust be posted (not draft).
amountYesNumber > 0. Cannot exceed the invoice's outstanding balance.
reasonNoFree 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

FieldRequiredNotes
nameYesInternal label. Shown in the dashboard, never to the customer.
source_invoice_idsYesArray of one or more posted invoice ids that seed the template's header + lines.
cadenceNoweekly | monthly (default) | quarterly | yearly | custom.
cron_expressionIf cadence = customStandard 5-field cron.
due_in_daysNoInteger ≥ 0. Defaults to 30.
start_atNoISO timestamp. Defaults to the next cadence tick.
is_activeNoBoolean, 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.

EventFires when
invoicing.customer.createdA customer is created (UI or API).
invoicing.customer.updatedA customer is updated.
invoicing.customer.archivedA customer is archived.
invoicing.invoice.createdA draft invoice is created.
invoicing.invoice.updatedA draft invoice is updated.
invoicing.invoice.postedAn invoice is posted (draft → sent).
invoicing.invoice.sentAn invoice is emailed to the customer.
invoicing.invoice.voidedAn invoice is voided.
invoicing.invoice.paidAn invoice's status flips to paid (first time only).
invoicing.invoice.overdueThe nightly sweep flips an invoice past its due date.
invoicing.invoice.reminder_sentA payment reminder email is sent.
invoicing.recurring.createdA recurring invoice template is created.
invoicing.credit_note.issuedA credit note is issued.
invoicing.refund.issuedA refund is issued.
invoicing.approval.requestedA post / refund / void enters an approval policy.
invoicing.approval.approvedAn approval step is approved.
invoicing.approval.rejectedAn approval step is rejected.
invoicing.approval.escalatedAn approval is escalated.
invoicing.approval.expiredAn approval SLA expires.
Want to try every endpoint without writing code? Drop your sk_test_* key into the Invoicing Playground - it walks through customer → invoice → post → mark-paid → refund in your sandbox tenant.