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:

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

request_trust.sh
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.

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

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://ma.manza.dev/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—
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—
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

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.