Transfer authorization
Let your own system approve API transfers to trusted payees, within limits your approvers set, instead of a person in the app.
How it works#
By default every transfer created with POST /api/payments waits for a manager or legal representative to approve it in the app. Transfer authorization adds a second path for high-volume integrations:
- An approver enrols one of your webhook endpoints in the app, with a limit per transfer and a limit per 24 hours.
- When you create a transfer that fits inside those limits and pays a trusted payee, it is not shown to your approvers. Instead your endpoint receives a
payment.authorization_requestedwebhook carrying a one-time nonce. - Your system checks the transfer against its own ledger, signs the transfer's fields with the endpoint's signing secret, and calls
POST /api/payments/:id/authorize. The transfer then executes exactly like one approved in the app.
Anything outside the limits goes to your approvers, the same as before. Nothing is rejected. That includes an untrusted payee, a transfer over either limit and a suspended authorizer, and it also covers a wrong or stale signature and a challenge that gets no answer within one hour.
Enrolment#
Enrolment happens only in the app: Settings → Developer → Transfer approval. The member needs permission to approve payments and to manage webhook endpoints, and confirms with their two-factor step. The API can never enrol, change limits or resume.
- Approving endpoint: one of your active webhook endpoints. While it is enrolled, its URL cannot change, its signing secret is hidden in the app and cannot be regenerated through the API.
- Currency, limit per transfer and limit per 24 hours: both limits are required.
- Signing secret lifetime: 90 days from enrolment or the last rotation. Approvers are reminded 14, 7 and 1 days before. When it expires, transfer authorization suspends itself and transfers go to your approvers.
To rotate the secret, an approver chooses Rotate secret on the same page and confirms with their two-factor step. The new secret is shown once and the 90 days start again. For the next 24 hours, authorizations signed with either the new or the previous secret are accepted, so you can deploy the new one without failing transfers. Webhooks are signed with the new secret straight away. A secret that has already expired gets no overlap, and rotating again within the 24 hours retires the oldest secret at once. After a rotation following an expiry, an approver resumes transfer authorization.
Any approver can suspend transfer authorization with one click in the app, or from the Suspend link in every alert email. No two-factor step is needed to suspend. Resuming needs one. It also suspends itself after five invalid signatures in a row, and when the endpoint is disabled or deleted.
Which transfers qualify#
A transfer is sent to your endpoint only when all of these hold, checked when the transfer is created and again when you authorize it:
| Check | Otherwise |
|---|---|
| Transfer authorization is enrolled, not suspended, its secret has not expired and the endpoint is active | suspended / secret_expired |
| The transfer's currency is the enrolled currency | currency_mismatch |
| The amount is at most the limit per transfer | over_transfer_limit |
| Amounts authorized in the last 24 hours plus this one stay within the limit per 24 hours | over_daily_limit |
| The payee is one of your own accounts, or a trusted payee | untrusted_payee |
A payee becomes trusted automatically once a transfer an approver approved in the app has settled to those exact bank details. Approvers can also mark payees as trusted from the Beneficiaries list, or your integration can ask them to (below). Trust follows the bank details: editing a payee's account number lapses it.
Request trust for payees#
POST /api/payee_trust_requests
Body: external_account_ids, a list of up to 100 of your beneficiaries' bank account ids. Requires the beneficiaries:request_trust scope. The member who created the key must be allowed to create transfers. Limited to 10 requests per minute per key.
201 returns the request with status pending. Nothing is trusted yet: your approvers get one task for the whole batch and approve it in the app with a second factor. An id your workspace does not have returns 422 with param external_account_ids. A leaked key can ask, but only a person can approve.
Poll GET /api/payee_trust_requests/:id (with beneficiaries:request_trust or beneficiaries:read) for status: approved or declined. It becomes cancelled when an approver tries to approve after every requested account was deleted; deleting accounts alone does not change it. Approval trusts the bank details as they are at that moment.
curl -X POST "https://ma.manza.dev/api/payee_trust_requests" \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"external_account_ids": ["<external account id>"]}'Your client reference#
Send your own id for the transfer as client_reference (a string of at most 128 characters) when you create it. It is unique per workspace: creating a second transfer with the same reference returns 409 duplicate_client_reference with the existing transfer's payment_id, so a retried create never makes a second transfer.
client_reference is part of the signed string. Only sign transfers whose client_reference your system issued. That check is what makes a leaked API key useless: a transfer an attacker creates is one your ledger has never seen.
The challenge webhook#
payment.authorization_requested is delivered only to the enrolled endpoint, signed like every other webhook (X-Manza-Signature). You cannot subscribe to it through events. It is delivered at most three times (the first attempt and two retries) within the hour the challenge stays open.
{
"event": "payment.authorization_requested",
"event_id": "01958e51-7c5a-7000-8000-000000000010",
"created_at": "2026-10-01T12:00:00Z",
"data": {
"authorization": {
"id": "01958e51-7c5a-7000-8000-000000000011",
"nonce": "9f86d081884c7d659a2feaa0c55ad015",
"expires_at": "2026-10-01T13:00:00Z",
"signature_version": "manza.transfer-authorization.v1"
},
"payment": {
"id": "01958e51-7c5a-7000-8000-000000000012",
"amount": "2500.0",
"currency_code": "ZAR",
"account_id": "01958e51-7c5a-7000-8000-000000000013",
"payee": "ext:01958e51-7c5a-7000-8000-000000000014",
"beneficiary_id": "01958e51-7c5a-7000-8000-000000000015",
"external_account_id": "01958e51-7c5a-7000-8000-000000000014",
"destination_account_id": null,
"payment_reference": "INV-2026-001",
"client_reference": "po_8812"
},
"signature_input": "manza.transfer-authorization.v1|01958e51-7c5a-7000-8000-000000000012|9f86d081884c7d659a2feaa0c55ad015|2500.0|ZAR|01958e51-7c5a-7000-8000-000000000013|ext:01958e51-7c5a-7000-8000-000000000014|po_8812"
}
}GET /api/payments/:id shows the open challenge as authorization with its id, status and expires_at. The nonce only ever appears in the webhook.
Signing#
Join these fields with | and compute a lowercase hex HMAC-SHA256 with the endpoint's signing secret:
manza.transfer-authorization.v1|<payment id>|<nonce>|<amount>|<currency>|<source account id>|<payee>|<client_reference>amountis the decimal string the API returns (for example2500.0).payeeisext:<external_account_id>for a beneficiary's bank account, orown:<destination_account_id>for one of your own accounts.client_referenceis empty when the transfer has none.
Build the string from your own ledger's values, not from signature_input in the webhook. signature_input is there so you can compare and debug. The fixed prefix means a webhook signature can never be replayed as an authorization.
input = ["manza.transfer-authorization.v1", payment_id, nonce, amount, currency,
account_id, payee, client_reference.to_s].join("|")
signature = OpenSSL::HMAC.hexdigest("SHA256", signing_secret, input)import { createHmac } from "node:crypto";
const input = ["manza.transfer-authorization.v1", paymentId, nonce, amount, currency,
accountId, payee, clientReference ?? ""].join("|");
const signature = createHmac("sha256", signingSecret).update(input).digest("hex");import hashlib, hmac
message = "|".join(["manza.transfer-authorization.v1", payment_id, nonce, amount, currency,
account_id, payee, client_reference or ""])
signature = hmac.new(signing_secret.encode(), message.encode(), hashlib.sha256).hexdigest()input := strings.Join([]string{"manza.transfer-authorization.v1", paymentID, nonce, amount,
currency, accountID, payee, clientReference}, "|")
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(input))
signature := hex.EncodeToString(mac.Sum(nil))Decline a transfer#
POST /api/payments/:id/decline
Body: authorization_id and an optional reason. No signature is needed. The transfer is deleted and 200 returns the challenge with status declined. Repeating the call returns the same answer. Same scope and creating-key rule as authorize.
Error responses#
| Scenario | Status | Type | Param |
|---|---|---|---|
| The creating API key tried to authorize or decline | 403 | same_key_forbidden | — |
| Key lacks transfers:authorize | 403 | insufficient_scope | — |
| The key's creating member may not approve or delete transfers | 403 | forbidden_error | — |
| release_api_webhook_authorization not enabled | 403 | forbidden_error | — |
| Unknown transfer or authorization_id in your workspace | 404 | not_found_error | — |
| authorization_id missing | 400 | invalid_request_error | — |
| Signature does not match | 422 | invalid_signature | — |
| Challenge older than one hour (sent to approvers) | 422 | authorization_expired | — |
| Transfer changed after the challenge (sent to approvers) | 422 | authorization_stale | — |
| Challenge already closed, or refused at answer time (sent to approvers) | 422 | state_error | — |
| Duplicate client_reference on create | 409 | duplicate_client_reference | client_reference |
| Authorization rate limit exceeded | 429 | rate_limit_error | — |
| Trust request names an unknown account, or more than 100 | 422 | validation_error | external_account_ids |
Keeping it safe#
- Keep the signing secret and the authorizing API key on the system that holds your ledger, apart from the key that creates transfers.
- Sign only transfers whose
client_referenceyou issued and whose amount, currency and payee match your record. - Approvers are alerted about every transfer your endpoint approves, on the cadence they choose: every transfer, above an amount with a daily digest, or the daily digest only. Each alert carries a Suspend link.