Payments
A payment moves USDC from one of your wallets to a
recipient identified by a @handle, email, wallet id, or raw address.
Because the source is a Safe multisig, a payment is a small three-step
lifecycle, not a single call:
propose → sign → execute
(create) (owner) (auto at threshold)
- Propose —
POST /v1/payments. The API resolves the destination, screens it, encodes the transfer calldata, and returns aPaymentwith asafeTxHashto sign. - Sign — you sign that
safeTxHashlocally with an owner key and submit the signature (POST /v1/payments/{id}/sign). - Execute — once the wallet's signature threshold is met, the API
auto-executes on-chain. (You can also drive it explicitly with
POST /v1/payments/{id}/execute.)
Status moves through:
CREATED → AWAITING_SIGNATURE → READY_TO_EXECUTE → EXECUTING → COMPLETED
↘ FAILED / CANCELLED
Key endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
POST | /v1/payments | payments:create | Propose a payment. |
POST | /v1/payments/{id}/sign | payments:create | Submit an owner signature. |
POST | /v1/payments/{id}/execute | payments:execute | Explicitly execute a ready payment. |
POST | /v1/payments/{id}/cancel | payments:create | Cancel a pending payment. |
GET | /v1/payments | payments:read | List payments (skip, take). |
GET | /v1/payments/{id} | payments:read | Get one payment. |
See the REST reference for full request/response schemas.
The signing gotcha (read this)
personal_signSign the raw 32-byte safeTxHash with a plain secp256k1 signature — with no
EIP-191 prefix. Using personal_sign / signMessage prefixes the hash and
breaks both the API's recoverAddress check and Safe's on-chain
verification. The SDK's signSafeTxHash does
this correctly (the dashboard uses Privy secp256k1_sign for the same reason).
The returned signature has v ∈ {27, 28} and is byte-compatible with what
POST /v1/payments/{id}/sign recovers.
Send a payment
- curl
- TypeScript
- CLI
# 1. Propose — returns a payment with a safeTxHash.
curl -X POST https://api.sandbox.safebank.ai/v1/payments \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sourceWalletId": "w_1a2b3c",
"destination": { "type": "handle", "handle": "volt-components" },
"amount": { "value": "25.00" },
"memo": "invoice INV-2026-001"
}'
# 2. Sign the returned safeTxHash locally (raw secp256k1), then submit it.
# Submitting the signature auto-executes once the threshold is met.
curl -X POST https://api.sandbox.safebank.ai/v1/payments/p_abc/sign \
-H "X-SafeBank-Api-Key: $SAFEBANK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "signature": "0x<signature-over-safeTxHash>" }'
import { SafeBank, signSafeTxHash } from '@safebank/sdk'
const sb = new SafeBank({ apiKey: process.env.SAFEBANK_API_KEY! })
// 1. Propose.
const payment = await sb.payments.create({
sourceWalletId: 'w_1a2b3c',
destination: { type: 'handle', handle: 'volt-components' },
amount: { value: '25.00' },
memo: 'invoice INV-2026-001',
})
if (payment.status === 'COMPLETED') {
// Nothing to sign (e.g. below-threshold auto-path).
} else {
// 2. Sign the safeTxHash — raw secp256k1, NOT personal_sign.
const signature = await signSafeTxHash(payment.safeTxHash!, privateKey)
// 3. Submit — auto-executes at threshold.
const settled = await sb.payments.sign(payment.id, signature)
console.log(settled.status, settled.executedTxHash)
}
# Proposes, signs with your demo signer, and executes — one command.
sb pay send --from w_1a2b3c --to @volt-components --amount 25.00 --memo "INV-2026-001"
Destinations
The destination object is a tagged union on type:
type | Shape | Notes |
|---|---|---|
handle | { "type": "handle", "handle": "volt-components" } | Resolved via the directory. |
email | { "type": "email", "email": "pay@acme.com" } | Resolved to a wallet if discoverable. |
address | { "type": "address", "address": "0x…", "chainId": 84532 } | Raw on-chain address. |
wallet | { "type": "wallet", "walletId": "w_recipient" } | Another wallet in your tenant. |
The amount is { "value": "25.00" } (USDC; asset defaults to usdc).
Payment response
{
"id": "p_abc",
"sourceWalletId": "w_1a2b3c",
"destinationType": "handle",
"destinationHandle": "volt-components",
"destinationAddress": "0x…",
"destinationChainId": 84532,
"asset": "usdc",
"amount": "25.00",
"status": "AWAITING_SIGNATURE",
"safeTxHash": "0x…",
"executedTxHash": null,
"memo": "invoice INV-2026-001",
"failureReason": null,
"createdAt": "2026-08-09T12:01:00.000Z"
}
After a successful sign + execute, status is COMPLETED and executedTxHash
is the on-chain transaction hash. To wait for settlement, poll
GET /v1/payments/{id} or use sb.waitForPayment(id).
Always send an Idempotency-Key on
POST /v1/payments (the SDK does this automatically) so a retried propose
never creates a duplicate payment.
Failures
A payment can land in FAILED (with a failureReason) — e.g. a spend policy
rejected it (POLICY_DENIED) or the on-chain execution reverted. Non-2xx
responses use the standard error envelope.