Skip to main content

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)
  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.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>" }'

Destinations

The destination object is a tagged union on type:

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

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.

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:

StepLegacy Safe-debitNormalized intent
CreatePOST /v1/payments (sourceWalletId + destination) → returns a safeTxHash to signPOST /v1/payments (recipient + amount) → 202, Permara starts resolving + gating
AuthorizePOST /v1/payments/{id}/sign — owner signs the raw safeTxHash locally; threshold auto-executesPOST /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 railn/a — the Safe transfer is the railGET /v1/payments/{id}/routes (quotes + why withheld routes were excluded), then POST /v1/payments/{id}/select-route
ExecutePOST /v1/payments/{id}/execute (explicit; auto at threshold)POST /v1/payments/{id}/confirm — locks funds against the selected quote and starts execution
Inspect / cancelGET /v1/payments/{id}, POST /v1/payments/{id}/cancelSame 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)
CREATEDCREATED
AWAITING_SOURCE_FUNDSFUNDS_PENDING
SOURCE_FUNDS_CONFIRMINGFUNDS_PENDING
SOURCE_FUNDS_CONFIRMEDFUNDS_CONFIRMED
RECIPIENT_RESOLUTIONIDENTITY_RESOLUTION
PENDING_CLAIMACTION_REQUIRED
PAYOUT_SELECTION_REQUIREDACTION_REQUIRED
POLICY_CHECKPOLICY_CHECK
COMPLIANCE_HOLD + hold reason APPROVAL_THRESHOLD_NOT_MET, no votes yetAPPROVAL_REQUIRED
COMPLIANCE_HOLD + APPROVAL_THRESHOLD_NOT_MET, ≥1 approve voteAPPROVAL_PENDING
COMPLIANCE_HOLD (any other hold reason)CREDENTIAL_HOLD
ROUTE_QUOTEDROUTE_QUOTED
AWAITING_RECIPIENT_CONFIRMATIONAWAITING_CONFIRMATION
FUNDS_LOCKEDROUTING
PROVIDER_SUBMITTEDPROVIDER_SUBMITTED
PROVIDER_PROCESSINGPROVIDER_PROCESSING
ACTION_REQUIREDACTION_REQUIRED
DELIVEREDDELIVERED
FAILEDFAILED
REFUND_PENDINGREFUND_PENDING
REFUNDEDREFUNDED
CANCELEDCANCELED

Notes:

  • APPROVAL_REQUIRED / APPROVAL_PENDING / CREDENTIAL_HOLD are hold-reason refinements of one internal state (COMPLIANCE_HOLD) — check holdReasons on the response for the specific reasons.
  • APPROVED and CREDENTIAL_CHECK exist in the public vocabulary for forward compatibility; today an approved payment moves straight back through POLICY_CHECK to ROUTE_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.