Skip to main content

Invoices (escrow)

An invoice is a payment request settled through escrow. Each invoice gets its own 2-of-3 hold Safe — owned by the payer, the payee, and a platform arbiter. The payer funds the Safe on-chain; the money only leaves as a release (→ payee) or a refund (→ payer), and each requires two of the three owners to sign. The platform can never move escrowed funds unilaterally.

Lifecycle:

DRAFT ──send──▶ SENT ──(funded)──▶ FUNDED ──deliver──▶ DELIVERED
│ │ │ │
cancel cancel / expire release / refund release / refund
▼ ▼ ▼ ▼
CANCELLED CANCELLED / EXPIRED RELEASED / REFUNDED RELEASED / REFUNDED

RELEASED, REFUNDED, and CANCELLED are terminal. Cancel is only possible before funds arrive (DRAFT / SENT). An unfunded invoice whose expiresAt passes auto-expires (see Expiry) — funds-win: once a hold Safe is funded the invoice becomes FUNDED and never expires. EXPIRED is recoverable, not terminal: its only exit is a refund that sweeps any funds sitting in the hold Safe back to the payer, so escrowed funds are never stranded (release is not possible after expiry).

Invoices are sandbox-only for now (Base Sepolia).

Trust model: custodial-with-arbiter, not trustless

The arbiter is a permanent 2-of-3 owner of every hold Safe (it defaults to the platform arbiter). Because any two owners can execute, the platform arbiter can co-sign with the payee to release (without the payer) or with the payer to refund (without the payee). This is a deliberate design choice — the arbiter exists to break disputes — but it means the escrow is custodial with an arbiter, not trustless. Supply your own arbiterAddress if you want a third party you control.

Create + send

create drafts the invoice; send provisions the hold Safe and returns the address to fund.

curl -X POST https://api.sandbox.safebank.ai/v1/invoices \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "amountUsd": "120.00", "payerAddress": "0x…payer", "payeeAddress": "0x…payee", "payeeName": "Volt Components" }'

curl -X POST https://api.sandbox.safebank.ai/v1/invoices/$INVOICE_ID/send \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY"
# → status SENT + holdAddress (send USDC here to fund it)

The arbiter defaults to the platform arbiter; pass arbiterAddress to use your own. Payer, payee, and arbiter must be three distinct addresses.

Funding

Send USDC (≥ the invoiced amount) to the hold Safe address. A background poller flips the invoice to FUNDED automatically once the balance arrives — no callback needed. A payer can use the unauthenticated public view to see what to pay:

GET /v1/invoices/public/{id}   →  { amountUsd, status, holdAddress, chainId }

Expiry

Set expiresAt (ISO-8601) when creating an invoice to bound how long it stays open for funding. The same background poller transitions a SENT invoice to EXPIRED once that timestamp passes and its hold Safe is still under-funded. Expiry is funds-win: if the balance covers the amount the invoice becomes FUNDED instead (even if funds land a little late), and a FUNDED invoice never expires. A never-sent draft is cancelled, not expired.

EXPIRED is recoverable, not a dead end. If funds ever end up in an expired invoice's hold Safe — a partial deposit, or a payment that lands right at the deadline — call refund on it: the refund is a recovery sweep that returns the Safe's actual balance to the payer (not the invoiced amount). An expired invoice that holds nothing returns INVOICE_NOTHING_TO_REFUND. Release is refused after expiry — money only ever goes back to the payer once the deal has lapsed.

The sweep captures the balance at the moment you initiate the refund. A straggler deposit that lands after a recovery refund is already in flight (or after the invoice has settled) is the same standing case as any late deposit to an already-settled invoice: the hold Safe is 2-of-3, so its owners (payer, payee, arbiter) can always co-sign to move it. So no funds are ever lost — the ordinary flow returns them automatically, and the rare straggler is recoverable directly on the Safe.

Release / refund (two signatures)

sb invoices deliver <invoiceId> --evidence "tracking #123"   # optional
sb invoices release <invoiceId> --owner 0x… # → payee
sb invoices refund <invoiceId> --owner 0x… # → payer

refund also works on an EXPIRED invoice — there it sweeps whatever the hold Safe actually holds back to the payer (see Expiry).

Release and refund propose a payment out of the hold Safe. Because the Safe is 2-of-3, the payment executes only after two owners sign it via the payments API — one signature moves it to SIGNING, the second executes. The invoice flips to RELEASED / REFUNDED once that payment completes. Only one settlement can be in flight at a time — release and refund compete for a single atomic claim, so two concurrent proposals can never both post (SETTLEMENT_IN_FLIGHT otherwise).

Key endpoints

MethodPathScope
POST/v1/invoicesinvoices:write
GET/v1/invoicesinvoices:read
GET/v1/invoices/{id}invoices:read
POST/v1/invoices/{id}/sendinvoices:write
POST/v1/invoices/{id}/deliverinvoices:write
POST/v1/invoices/{id}/releaseinvoices:release
POST/v1/invoices/{id}/refundinvoices:release
POST/v1/invoices/{id}/cancelinvoices:write
GET/v1/invoices/public/{id}(public)

See the REST reference for full request/response schemas.