Skip to main content

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)
  1. ProposePOST /v1/payments. The API resolves the destination, screens it, encodes the transfer calldata, and returns a Payment with a safeTxHash to sign.
  2. Sign — you sign that safeTxHash locally with an owner key and submit the signature (POST /v1/payments/{id}/sign).
  3. 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

MethodPathScopePurpose
POST/v1/paymentspayments:createPropose a payment.
POST/v1/payments/{id}/signpayments:createSubmit an owner signature.
POST/v1/payments/{id}/executepayments:executeExplicitly execute a ready payment.
POST/v1/payments/{id}/cancelpayments:createCancel a pending payment.
GET/v1/paymentspayments:readList payments (skip, take).
GET/v1/payments/{id}payments:readGet one payment.

See the REST reference for full request/response schemas.

The signing gotcha (read this)

Sign the raw hash, never personal_sign

Sign 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

# 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>" }'

Destinations

The destination object is a tagged union on type:

typeShapeNotes
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).

Idempotency

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.