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).
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
- TypeScript
- CLI
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)
const invoice = await sb.invoices.create({
amountUsd: '120.00',
payerAddress: payer,
payeeAddress: payee,
payeeName: 'Volt Components',
})
const sent = await sb.invoices.send(invoice.invoiceId)
console.log('fund the hold Safe:', sent.holdAddress)
sb invoices create --amount 120 --payer 0x…payer --payee 0x…payee --payee-name "Volt"
sb invoices send <invoiceId> # prints the hold Safe address to fund
sb invoices status <invoiceId> # FUNDED once the balance arrives
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
| Method | Path | Scope |
|---|---|---|
POST | /v1/invoices | invoices:write |
GET | /v1/invoices | invoices:read |
GET | /v1/invoices/{id} | invoices:read |
POST | /v1/invoices/{id}/send | invoices:write |
POST | /v1/invoices/{id}/deliver | invoices:write |
POST | /v1/invoices/{id}/release | invoices:release |
POST | /v1/invoices/{id}/refund | invoices:release |
POST | /v1/invoices/{id}/cancel | invoices:write |
GET | /v1/invoices/public/{id} | (public) |
See the REST reference for full request/response schemas.