Payments
A payment moves USDC from one of your wallets to a
recipient identified by a Permara ID, email, wallet id, or raw address (a @handle on the legacy wallet directory still resolves and is being removed).
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.permara.com/v1/payments \
-H "X-SafeBank-Api-Key: $PERMARA_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.permara.com/v1/payments/p_abc/sign \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "signature": "0x<signature-over-safeTxHash>" }'
import { Permara, signSafeTxHash } from '@permara/sdk'
const pm = new Permara({ apiKey: process.env.PERMARA_API_KEY! })
// 1. Propose.
const payment = await pm.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 pm.payments.sign(payment.id, signature)
console.log(settled.status, settled.executedTxHash)
}
# Proposes, signs with your demo signer, and executes — one command.
permara 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" } | Legacy wallet directory (being removed) — 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 pm.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.
Migration: the normalized payment intent
POST /v1/payments now prefers the normalized intent shape: describe the
payment and Permara picks the rail.
{
"recipient": { "handle": "@acme-ops" },
"amount": { "value": "250.00", "currency": "USD" },
"delivery": { "preference": "instant", "maximumFeeMinor": "300" },
"purpose": { "type": "invoice", "invoiceId": "inv_123" }
}
Recipients resolve by did (a Permara ID), phoneNumber, email, or identityId; handle is the legacy wallet directory.
Responses carry the normalized status vocabulary (APPROVAL_REQUIRED,
ROUTE_QUOTED, DELIVERED, …) plus internalStatus for diagnostics. Route
quotes (with speedClass/guaranteeLevel and why withheld routes were
excluded) live at GET /v1/payments/{id}/routes.
The legacy Safe-debit shape keeps working unchanged on the same endpoint
— requests with sourceWalletId route to the existing propose/sign/execute
machinery and are answered with a Deprecation: true header. Migrate by
switching your create body to the intent shape; ids, GET, and cancel work
for both generations. Raw-wallet recipients stay on the legacy shape.
SDK: payments.createIntent(...), payments.routes(id),
payments.selectRoute(id, routeId), payments.approve(id),
payments.confirm(id). CLI: permara intents ….
Lifecycle mapping: propose/sign/execute → create/routes/select-route/approve/confirm
The legacy shape drives a Safe transaction; the intent shape drives a routed payment. Same endpoint family, different verbs:
| Step | Legacy Safe-debit | Normalized intent |
|---|---|---|
| Create | POST /v1/payments (sourceWalletId + destination) → returns a safeTxHash to sign | POST /v1/payments (recipient + amount) → 202, Permara starts resolving + gating |
| Authorize | POST /v1/payments/{id}/sign — owner signs the raw safeTxHash locally; threshold auto-executes | POST /v1/payments/{id}/approve — only when the status is APPROVAL_REQUIRED/APPROVAL_PENDING; votes the calling member on the payment's approval request (segregation of duties: the initiator cannot approve their own payment) |
| Pick the rail | n/a — the Safe transfer is the rail | GET /v1/payments/{id}/routes (quotes + why withheld routes were excluded), then POST /v1/payments/{id}/select-route |
| Execute | POST /v1/payments/{id}/execute (explicit; auto at threshold) | POST /v1/payments/{id}/confirm — locks funds against the selected quote and starts execution |
| Inspect / cancel | GET /v1/payments/{id}, POST /v1/payments/{id}/cancel | Same paths — ids are shared across generations; the API answers whichever generation the id belongs to |
Legacy sign/execute keep working unchanged and answer with
Deprecation: true, a Sunset date (30 Jun 2027), and a Link: …; rel="deprecation" header pointing here. The route is not deprecated —
only the legacy request shape.
Status vocabulary mapping
Unified responses project the internal payment machine onto a normalized
vocabulary; internalStatus always carries the raw value. The mapping is a
pure projection (the internal state machine is unchanged):
Internal (internalStatus) | Unified (status) |
|---|---|
CREATED | CREATED |
AWAITING_SOURCE_FUNDS | FUNDS_PENDING |
SOURCE_FUNDS_CONFIRMING | FUNDS_PENDING |
SOURCE_FUNDS_CONFIRMED | FUNDS_CONFIRMED |
RECIPIENT_RESOLUTION | IDENTITY_RESOLUTION |
PENDING_CLAIM | ACTION_REQUIRED |
PAYOUT_SELECTION_REQUIRED | ACTION_REQUIRED |
POLICY_CHECK | POLICY_CHECK |
COMPLIANCE_HOLD + hold reason APPROVAL_THRESHOLD_NOT_MET, no votes yet | APPROVAL_REQUIRED |
COMPLIANCE_HOLD + APPROVAL_THRESHOLD_NOT_MET, ≥1 approve vote | APPROVAL_PENDING |
COMPLIANCE_HOLD (any other hold reason) | CREDENTIAL_HOLD |
ROUTE_QUOTED | ROUTE_QUOTED |
AWAITING_RECIPIENT_CONFIRMATION | AWAITING_CONFIRMATION |
FUNDS_LOCKED | ROUTING |
PROVIDER_SUBMITTED | PROVIDER_SUBMITTED |
PROVIDER_PROCESSING | PROVIDER_PROCESSING |
ACTION_REQUIRED | ACTION_REQUIRED |
DELIVERED | DELIVERED |
FAILED | FAILED |
REFUND_PENDING | REFUND_PENDING |
REFUNDED | REFUNDED |
CANCELED | CANCELED |
Notes:
APPROVAL_REQUIRED/APPROVAL_PENDING/CREDENTIAL_HOLDare hold-reason refinements of one internal state (COMPLIANCE_HOLD) — checkholdReasonson the response for the specific reasons.APPROVEDandCREDENTIAL_CHECKexist in the public vocabulary for forward compatibility; today an approved payment moves straight back throughPOLICY_CHECKtoROUTE_QUOTED.- Webhook
payment.*events fire on projected transitions — two internal states sharing a public state emit one event, never two. - The legacy Safe-debit machine (
CREATED → AWAITING_SIGNATURE → READY_TO_EXECUTE → EXECUTING → COMPLETED / FAILED / CANCELLED) is unchanged and returned verbatim for legacy-shape payments.