Approvals & authority
Authority is what a member may do with the organization's money — which actions, within which limits, over which assets/chains/destinations, and with what approval weight. Approvals are how payments that exceed someone's authority get authorized by others. Two rules run through everything:
- Money authority is attested by your Safe, never by Permara. An
authority grant becomes cryptographic (
AUTHORITY_DELEGATIONcredential) only when your org's deployed Safe signs it, verified on-chain via EIP-1271. Permara holds no key that can mint spending authority. - Segregation of duties. The member who initiated a payment can never
approve it — the vote is refused with
SELF_APPROVAL_FORBIDDEN, not silently discounted.
Granting authority
POST /v1/members/{memberId}/authorities (members:manage). Only ACTIVE
members can hold authority.
curl -X POST https://api.sandbox.permara.com/v1/members/mem_1a2b3c/authorities \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"allowedActions": ["payment.create", "payment.approve"],
"perTxLimitUsd": "5000",
"dailyLimitUsd": "20000",
"monthlyLimitUsd": "100000",
"allowedChains": ["base"],
"approvalWeight": 1,
"countersignAboveUsd": "1000"
}'
The response contains the created authority plus attestation
instructions — the exact EIP-712 CredentialAttestation message your
Safe must sign:
{
"authority": {
"id": "auth_7g8h9i",
"memberId": "mem_1a2b3c",
"status": "ACTIVE",
"allowedActions": ["payment.approve", "payment.create"],
"perTxLimitUsd": "5000",
"approvalWeight": 1,
"requiresCountersignature": false,
"countersignAboveUsd": "1000",
"version": 1,
"credentialId": null
},
"attestation": {
"primaryType": "CredentialAttestation",
"message": {
"subjectDid": "did:safebank:c1a2b…",
"orgDid": "did:safebank:c9z8y…",
"kind": "AUTHORITY_DELEGATION",
"schemaVersion": "safebank/credential/AUTHORITY_DELEGATION/v1",
"docHash": "0x…",
"issuedAt": "1787997900",
"expiresAt": "0",
"nonce": "241531…"
},
"note": "Safe-sign the EIP-712 hash of this message (chainId = the tenant Safe chain), then POST the signature blob to /attest."
}
}
Semantics worth knowing:
- Limits:
perTxLimitUsdcaps a single payment;dailyLimitUsd/monthlyLimitUsdare rolling member spend windows. Hitting any of them holds the payment withAUTHORITY_LIMIT_EXCEEDED. - Allowlists (
allowedCurrencies/allowedAssets/allowedChains/allowedDestinationIds): empty = unrestricted; non-empty = exclusive. - Countersignature:
requiresCountersignature: true(always) orcountersignAboveUsd(above a threshold) forces the approval lane even within the per-tx limit. - Versioning: grants are versioned per member; the most-recent ACTIVE
grant covering the action wins deterministically. A REVOKED authority is
immutable — grant a new one (
AUTHORITY_REVOKEDon update attempts).
Attesting with the org Safe (EIP-1271)
The grant is effective immediately for gating; attestation makes it a signed, portable credential. The document and nonce derive deterministically from the stored row, so there is no pending-signature state — you can recompute and sign at any time:
- Compute the EIP-712 hash of
attestation.message(types/domain in@permara/attestations—credentialAttestationHash; domain{ name: 'Permara', version: '1', chainId: <your Safe's chain>, verifyingContract: '0x5afeba…0002' }). - Collect owner signatures through your normal Safe signing flow until
the Safe's threshold is met (Safe UI, Safe Transaction Service, or
signMessageon the Safe). - Submit the combined signature blob:
curl -X POST https://api.sandbox.permara.com/v1/members/mem_1a2b3c/authorities/auth_7g8h9i/attest \
-H "X-SafeBank-Api-Key: $PERMARA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "signature": "0x…combined-safe-signature…" }'
The API verifies the signature on-chain (isValidSignature via
eth_call against your Safe) and, if the threshold is met, creates the
AUTHORITY_DELEGATION credential (issuer = your Safe's address) in the
same transaction. Failure modes:
| Code | Status | Meaning |
|---|---|---|
ORG_SAFE_NOT_DEPLOYED | 409 | Your tenant Safe is predicted-address only — deploy it first. There is no fallback signer. |
ORG_ATTESTATION_INVALID | 400 | The signature does not validate on-chain (threshold not met, or signed the wrong hash). |
AUTHORITY_ALREADY_ATTESTED | 409 | The authority already carries a credential. |
AUTHORITY_NOT_ACTIVE | 409 | Suspended/revoked authorities can't be attested. |
Revoking an authority (DELETE …/authorities/{id}) revokes its backing
credential atomically.
The approval lane
When the credential gate decides a payment needs approval (flat
countersignature, countersignAboveUsd, or an org payment-policy rule),
the payment parks in APPROVAL_REQUIRED with a PENDING approval request
(at most one open request per payment — concurrent holds converge on one).
create payment ──gate──▶ APPROVAL_REQUIRED ──votes──▶ APPROVAL_PENDING ──Σweight ≥ required──▶ payment re-enters POLICY_CHECK → ROUTE_QUOTED …
└─ any REJECT vote ──▶ request REJECTED
Vote from a member session (approvals come from members, not
applications — an API key gets APPROVAL_REQUIRES_MEMBER):
# List open requests
curl https://api.sandbox.permara.com/v1/approvals \
-H "Authorization: Bearer $PRIVY_TOKEN"
# Vote (optionally with an EIP-191 wallet signature for non-repudiation)
curl -X POST https://api.sandbox.permara.com/v1/approvals/apr_123/sign \
-H "Authorization: Bearer $PRIVY_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "decision": "APPROVE", "signature": "0x…" }'
# Or the payment-scoped shortcut:
curl -X POST https://api.sandbox.permara.com/v1/payments/pay_456/approve \
-H "Authorization: Bearer $PRIVY_TOKEN"
The optional signature is personal_sign over the canonical vote text:
Permara approval vote
request: <approvalRequestId>
payment: <paymentId>
decision: APPROVE
member: <memberId>
Voting rules (each refused with a stable code):
| Rule | Code |
|---|---|
| Only PENDING requests accept votes | APPROVAL_NOT_PENDING (409) |
| Voter must be a member, not an API key | APPROVAL_REQUIRES_MEMBER (403) |
| Initiator can never approve their own payment | SELF_APPROVAL_FORBIDDEN (403) |
| Voter must be an ACTIVE member | MEMBER_NOT_ACTIVE (403) |
Voter needs an ACTIVE authority granting payment.approve | AUTHORITY_MISSING (403) |
| Group-scoped requests need the matching approval group | APPROVAL_GROUP_MISMATCH (403) |
| One vote per member | ALREADY_VOTED (409) |
| Bad wallet signature | APPROVAL_SIGNATURE_INVALID (400) |
Each APPROVE vote contributes the voter's approvalWeight captured at
signing — later authority edits never rewrite past votes. When
Σ weights ≥ requiredWeight the request flips APPROVED and the payment
automatically re-enters policy evaluation (the gate now sees the approval
and quoting proceeds). Any REJECT vote rejects the request.
On-chain Safe-signature equivalence
Safe confirmations are approvals. For payments that execute through
your Safe, Permara sums the approval weights of ACTIVE members whose
registered signerAddress confirmed the Safe transaction and credits that
weight against the approval requirement — signing the Safe tx is the vote;
there is never a second signature collection on top of the Safe's M-of-N.
(Off-chain payouts are exactly why the approval lane exists: a provider
payout is not a contract call, so without this lane it would bypass your
approval requirements entirely.)
Endpoint summary
| Method | Path | Scope |
|---|---|---|
GET | /v1/members/{memberId}/authorities | members:read |
POST | /v1/members/{memberId}/authorities | members:manage |
PATCH / DELETE | /v1/members/{memberId}/authorities/{authorityId} | members:manage |
POST | /v1/members/{memberId}/authorities/{authorityId}/attest | members:manage |
GET | /v1/payment-policy (+ /history) | policies:read |
PUT | /v1/payment-policy, POST /v1/payment-policy/attest | policies:write |
GET | /v1/approvals | payments:read |
POST | /v1/approvals/{id}/sign / /{id}/reject | payments:execute |
POST | /v1/payments/{id}/approve | payments:execute |