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:

| 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, 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 payee | `untrusted_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.

```shell
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.

```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.

```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.

```ruby
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)
```

```javascript
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");
```

```python
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()
```

```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.

```shell
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

| Scenario | Status | Type | Param |
| --- | --- | --- | --- |
| The creating API key tried to authorize or decline | 403 | `same_key_forbidden` | — |
| The authorizing or declining key's IP allowlist allows every address | 403 | `unrestricted_api_key` | — |
| 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` | — |
| The payee's bank details were being edited, trusted or revoked at that moment (retry) | 409 | `authorization_busy` | — |
| 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` |
| GET /api/transfer_authorizer with nothing enrolled | 404 | `not_found_error` | — |
| GET /api/transfer_authorizer without a transfers scope | 403 | `insufficient_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](https://zazu.africa/docs/api/webhook-endpoints#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/`](https://github.com/getmanza/manza-ruby/tree/main/examples/transfer_authorizer). Only the Ruby client ships this example today; with the other [official clients](https://zazu.africa/docs/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 see | What it means | Fix |
| --- | --- | --- |
| `untrusted_payee` | The 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 trusting | Request 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_key` | The 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 since | Add 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_limit` | The payee is not trusted yet and the amount is above the limit per transfer to a new payee | Pay up to that limit first (its signature trusts the payee), or have an approver raise it |
| `over_transfer_limit` | The amount is above the limit per transfer | Split the payment, or have an approver raise the limit |
| `over_daily_limit` | This amount plus the last 24 hours' authorized total is above the limit per 24 hours | Wait for older authorizations to leave the window, or raise the limit; see `daily_authorized_total` |
| `currency_mismatch` | The source account's currency is not the enrolled currency | Pay from an account in the enrolled currency, or enrol that currency |
| `suspended` | Transfer authorization is suspended, or its endpoint is disabled | Fix 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_expired` | The signing secret passed `secret_expires_at` | An approver rotates the secret in the app and resumes; deploy the new secret |
| `signature_failed` | Five invalid signatures on one challenge | Build the signed string from the fields in [Signing](#signing), with the current secret |
| `stale` | The transfer, the payee's bank details or the enrolled endpoint changed after the challenge was sent | Create a new transfer; do not edit it or its payee while a challenge is open |
| `expired` | Nobody answered the challenge within one hour | Check that your receiver got the webhook and called authorize |
| `authorization` is null | No transfer authorizer was enrolled, or the feature was off, when the transfer was created | Run the setup steps above; `GET /api/transfer_authorizer` returns 404 or 403 |
| No webhook arrived, but `authorization` is `pending` | The delivery failed or went to an old URL | Check the endpoint's delivery log in the app and that its URL still reaches your receiver |