REST API

Transfer authorization

Let your own system approve API transfers to trusted payees, within limits your approvers set, instead of a person in the app.

Setup, step by step#

Several steps are manual on purpose: each one is a person deciding that a machine may move money. Do them in this order, and check each one before the next.

  1. Support enables the features. Ask support to turn on release_api_transfers and release_api_webhook_authorization for your workspace. Manual because it is a contract decision. Check, once you have key A from step 2: GET /api/transfer_authorizer returns 404 not_found_error, not 403 forbidden_error.
  2. Create two API keys. Key A creates transfers (transfers:write, transfers:read). Key B authorizes them (transfers:authorize, with an IP allowlist). Key B must be created by a member who may approve and delete payments. Manual because the app never lets one key do both. Check: each key's scopes in Settings → Developer → API keys.
  3. Expose your receiver on a stable HTTPS URL. Use your server, a reserved ngrok domain or a named cloudflared tunnel. A free ngrok URL changes on every restart, and an enrolled endpoint's URL cannot change, so you would have to enrol again. Check: the URL answers POST with 2xx from outside your network.
  4. Create the webhook endpoint for that URL (POST /api/webhook_endpoints or the app). Check: POST /api/webhook_endpoints/:id/test reaches your receiver and the signature verifies.
  5. Enrol it in Settings → Developer → Transfer approval, with the currency and both limits, and confirm with the two-factor step. Copy the signing secret: it is the one your receiver verifies webhooks with and signs challenges with. Manual because only a person may grant the privilege. Check: the page shows Active.
  6. Turn on Approve transfers to new payees at enrolment, or trust payees (a beneficiary's bank account; a transfer to one of your own accounts needs no trust). With the option on, your endpoint may approve a transfer to bank details nobody has trusted yet, up to the Limit per transfer to a new payee, and its signature trusts them. Otherwise file a trust request (below) that an approver accepts, have an approver mark the payee trusted from the Beneficiaries list, or let one approved transfer settle to it. Manual because only a person may decide that a machine picks where money goes. Check: the Transfer approval page shows New payees as approved, or trusted is true on GET /api/beneficiaries/:id/external_accounts/:id.
  7. Run a preflight. GET /api/transfer_authorizer must say "status": "active", the right currency_code, and room under daily_limit after daily_authorized_total. A beneficiary's account must say "trusted": true, unless Approve transfers to new payees is on and its bank details were never trusted.
  8. Create a test transfer with key A, within both limits, with a client_reference your system issued. Check: the response's authorization has status pending. Status refused with a refusal_reason means it went to your approvers; the table at the end of this page says why.
  9. Answer the challenge. Verify the webhook's signature, check the transfer against your ledger, sign the challenge and call POST /api/payments/:id/authorize with key B. Check: the transfer's status moves to processing.

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:

  1. An approver enrols one of your webhook endpoints in the app, with a limit per transfer and a limit per 24 hours.
  2. 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_requested webhook carrying a one-time nonce.
  3. 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.

Feature-flagged
Transfer authorization needs the release_api_webhook_authorization feature on your workspace, on top of API transfers. Contact support to enable it.

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:

CheckOtherwise
Transfer authorization is enrolled, not suspended, its secret has not expired and the endpoint is activesuspended / secret_expired
The transfer's currency is the enrolled currencycurrency_mismatch
The amount is at most the limit per transferover_transfer_limit
Amounts authorized in the last 24 hours plus this one stay within the limit per 24 hoursover_daily_limit
The payee is one of your own accounts or a trusted payee, or — with Approve transfers to new payees on — bank details never trusted, created with an API key that has an IP allowlist, for an amount at most the limit per transfer to a new payeeuntrusted_payee / unrestricted_api_key / over_new_payee_limit

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.

Approve transfers to new payees is an option an approver turns on at enrolment or later, with the two-factor step. With it on, a transfer to bank details your workspace never trusted is sent to your endpoint too, up to the Limit per transfer to a new payee (the limit per transfer when left empty), and a valid signature both executes it and trusts those details. The 24-hour limit still applies. Details a member revoked trust for, and an account whose trusted details were edited, still go to your approvers. If the payee's bank details change while the challenge is open, it is superseded as stale.

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.

request_trust.sh
curl -X POST "https://zazu.africa/api/payee_trust_requests" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"external_account_ids": ["<external account id>"]}'

Check the authorizer#

GET /api/transfer_authorizer

Read-only, for checking from code that transfer authorization is live before you create a transfer. Any key with transfers:read, transfers:write or transfers:authorize may call it. Enrolment, limits and resume stay in the app.

Returns 200 with status (active, or suspended whenever transfers would go to your approvers for that reason, including an expired secret or a disabled endpoint the system has not suspended yet), suspension_reason (member, signature_failures, secret_expired or endpoint_disabled, null when active), currency_code, per_transfer_limit, daily_limit, daily_authorized_total (amounts authorized in the last 24 hours, in the enrolled currency), secret_expires_at, webhook_endpoint_id and enrolled_at. It never returns the endpoint URL or a secret. 404 not_found_error means nothing is enrolled.

transfer_authorizer.json
{
  "status": "active",
  "suspension_reason": null,
  "currency_code": "ZAR",
  "per_transfer_limit": "1000000.0",
  "daily_limit": "1000000.0",
  "daily_authorized_total": "0.0",
  "secret_expires_at": "2027-01-18T00:00:00Z",
  "webhook_endpoint_id": "01958e51-7c5a-7000-8000-000000000020",
  "enrolled_at": "2026-10-20T08:30:00Z"
}

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.

payment.authorization_requested.json
{
  "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",
      "payee_trusted": false
    },
    "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"
  }
}

payment.payee_trusted says whether the payee's bank details were already trusted when the challenge was issued (always true for your own accounts). It is not part of the signed string. With Approve transfers to new payees on, use it to apply your own rule to a new payee, for example a lower amount or a check against the beneficiary you created.

GET /api/payments/:id shows the open challenge as authorization with its id, status, expires_at and refusal_reason. The nonce only ever appears in the webhook. A transfer refused when it was created has an authorization with status refused, its reason, and an expires_at equal to when it was created; no webhook is sent for it.

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>
  • amount is the decimal string the API returns (for example 2500.0).
  • payee is ext:<external_account_id> for a beneficiary's bank account, or own:<destination_account_id> for one of your own accounts.
  • client_reference is 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.

sign.rb
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)
sign.mjs
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");
sign.py
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()
sign.go
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))

Authorize a transfer#

POST /api/payments/:id/authorize

Body: authorization_id (from the webhook) and signature. Requires the transfers:authorize scope on a different API key from the one that created the transfer: the creating key is always refused with 403 same_key_forbidden, even when it holds both scopes. Use one key to create and another, kept with your signing code, to authorize. Limited to 10 requests per minute per key.

A valid signature returns 200 with the transfer, now executing. A retried call with the same valid signature returns the same 200 and executes nothing twice. If the first call failed before the transfer started executing, the retry checks the challenge again, so it can return authorization_expired, authorization_stale or state_error instead. A wrong signature on an authorized challenge returns 422 invalid_signature and does not count toward the five attempts. On an open challenge, an invalid signature returns 422 invalid_signature; after five on one challenge the transfer goes to your approvers.

authorize.sh
curl -X POST "https://zazu.africa/api/payments/<payment id>/authorize" \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"authorization_id": "<authorization id>", "signature": "<hex signature>"}'

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#

ScenarioStatusTypeParam
The creating API key tried to authorize or decline403same_key_forbidden—
The authorizing or declining key's IP allowlist allows every address403unrestricted_api_key—
Key lacks transfers:authorize403insufficient_scope—
The key's creating member may not approve or delete transfers403forbidden_error—
release_api_webhook_authorization not enabled403forbidden_error—
Unknown transfer or authorization_id in your workspace404not_found_error—
authorization_id missing400invalid_request_error—
Signature does not match422invalid_signature—
Challenge older than one hour (sent to approvers)422authorization_expired—
Transfer changed after the challenge (sent to approvers)422authorization_stale—
Challenge already closed, or refused at answer time (sent to approvers)422state_error—
The payee's bank details were being edited, trusted or revoked at that moment (retry)409authorization_busy—
Duplicate client_reference on create409duplicate_client_referenceclient_reference
Authorization rate limit exceeded429rate_limit_error—
Trust request names an unknown account, or more than 100422validation_errorexternal_account_ids
GET /api/transfer_authorizer with nothing enrolled404not_found_error—
GET /api/transfer_authorizer without a transfers scope403insufficient_scope—

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_reference you issued and whose amount, currency and payee match your record.
  • Only turn on Approve transfers to new payees once your ledger check on client_reference is in place: with it on, your signature alone decides which new bank details get paid and trusted. The challenge names the payee by external_account_id, not its bank details, so for a new payee compare GET /api/beneficiaries/:id/external_accounts/:id with the account you created before signing.
  • Give every key that creates transfers an IP allowlist. A transfer to a new payee from a key without one goes to your approvers (unrestricted_api_key), so a leaked key cannot reach your signer with bank details of its own.
  • 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.

Testing locally#

Your receiver must be reachable over HTTPS on a URL that does not change, because an enrolled endpoint's URL is fixed:

  • ngrok with a reserved domain: ngrok http --url=<your-name>.ngrok.app 3000.
  • cloudflared with a named tunnel routed to a hostname you own: cloudflared tunnel run <tunnel-name>. A quick tunnel (--url) gets a new hostname each run.

Before you enrol, send POST /api/webhook_endpoints/:id/test to check that a delivery reaches your receiver and that your signature check passes (see Send test event). The challenge webhook itself is only sent to an enrolled endpoint.

A complete receiver that verifies the webhook, checks a ledger, signs and authorizes is in the Ruby client's examples/transfer_authorizer/. Only the Ruby client ships this example today; with the other official clients, port it using their webhook verifier and authorize call.

Why did my transfer go to the approvers?#

Read authorization on GET /api/payments/:id. A transfer that did not qualify has an authorization with status refused (or expired, superseded or failed once a challenge closed) and a refusal_reason. The same row appears in Settings → Developer → Transfer approval → Recent approval requests. Nothing is rejected: the approvers can still approve it in the app.

What you seeWhat it meansFix
untrusted_payeeThe payee's bank details are not trusted: never trusted with Approve transfers to new payees off, or a member revoked their trust, or they were edited after trustingRequest trust (POST /api/payee_trust_requests) and have an approver accept it; for details never trusted, an approver may instead turn on Approve transfers to new payees. Revoked or edited details always need an approver. Check trusted on the account
unrestricted_api_keyThe payee is not trusted yet and the API key that created the transfer has no IP allowlist (or one whose networks together allow every address), or was revoked sinceAdd an IP allowlist to that key in Settings → Developer → API keys, as the transfers:authorize key already has; a revoked key cannot be edited, so have an approver approve this transfer, or delete it in the app and create a new one (with a new client_reference) from an active key that has an IP allowlist
over_new_payee_limitThe payee is not trusted yet and the amount is above the limit per transfer to a new payeePay up to that limit first (its signature trusts the payee), or have an approver raise it
over_transfer_limitThe amount is above the limit per transferSplit the payment, or have an approver raise the limit
over_daily_limitThis amount plus the last 24 hours' authorized total is above the limit per 24 hoursWait for older authorizations to leave the window, or raise the limit; see daily_authorized_total
currency_mismatchThe source account's currency is not the enrolled currencyPay from an account in the enrolled currency, or enrol that currency
suspendedTransfer authorization is suspended, or its endpoint is disabledFix the cause in suspension_reason (re-enable the endpoint, rotate the secret); when it was already suspended, an approver then resumes it in the app
secret_expiredThe signing secret passed secret_expires_atAn approver rotates the secret in the app and resumes; deploy the new secret
signature_failedFive invalid signatures on one challengeBuild the signed string from the fields in Signing, with the current secret
staleThe transfer, the payee's bank details or the enrolled endpoint changed after the challenge was sentCreate a new transfer; do not edit it or its payee while a challenge is open
expiredNobody answered the challenge within one hourCheck that your receiver got the webhook and called authorize
authorization is nullNo transfer authorizer was enrolled, or the feature was off, when the transfer was createdRun the setup steps above; GET /api/transfer_authorizer returns 404 or 403
No webhook arrived, but authorization is pendingThe delivery failed or went to an old URLCheck the endpoint's delivery log in the app and that its URL still reaches your receiver