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.

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

```shell
curl -X POST "https://za.manza.finance/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.

```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"
    },
    "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>
```

- `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://za.manza.finance/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` | — |
| 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_reference` you 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.