manza-elixir

Elixir SDK for the Manza API.

# mix.exs
def deps do
  [
    {:manza, github: "getmanza/manza-elixir"}
  ]
end
{:ok, client} = Manza.new(api_key: System.fetch_env!("MANZA_API_KEY"))

{:ok, entity} = Manza.Entity.get(client)

{:ok, page} = Manza.Accounts.list(client)

for account <- page.data do
  IO.puts("#{account["id"]} #{account["name"]}")
end

# Initiate a transfer. Creating it never executes it: it goes to your
# enrolled transfer authorizer or to your workspace's in-app approvers.
{:ok, draft} =
  Manza.Payments.create(client, %{
    "account_id" => account_id,
    "beneficiary_id" => beneficiary_id,
    "amount" => "150.00",
    "payment_reference" => "INV-000042"
  })

Hosts

The default base URL is production Morocco, https://ma.manza.finance. For South Africa pass base_url: "https://za.manza.finance" (or set MANZA_BASE_URL). The replay cassettes are recorded against https://ma.manza.dev.

Environment variables

MANZA_API_KEY, MANZA_BASE_URL and MANZA_API_VERSION are read when the matching option is not passed. The old ZAZU_API_KEY, ZAZU_BASE_URL and ZAZU_API_VERSION names still work for all of 1.x as a fallback and log a one-time deprecation warning per variable.

Resources

Module Calls
Manza.Entity get/1
Manza.Accounts list/2, get/2, list_transactions/3, get_transaction/3
Manza.Customers list/2, get/2, create/2, update/3, delete/2
Manza.Invoices list, get, create, update, send, mark as paid, cancel, credit note, delete, payment link
Manza.PaymentLinks list/2, get/2, create/2, cancel/2
Manza.CheckoutSessions create/2, get/2
Manza.WebhookEndpoints CRUD, enable/disable, test, regenerate secret
Manza.Beneficiaries list/2, get/2, create/2, list_external_accounts/3, get_external_account/3 ("trusted" once a trusted payee), create_external_account/3
Manza.PayeeTrustRequests create/2 (external_account_ids), get/2
Manza.Payments create/2, get/2 ("authorization": "status", "refusal_reason"), authorize/4, decline/4
Manza.TransferAuthorizer get/1: status, suspension_reason, limits, daily_authorized_total; :not_found when none is enrolled
Manza.Webhook construct_event/4, construct_event!/4, verify?/5, sign/3 (pure, no HTTP)

Manza.TransferDrafts is a deprecated alias of Manza.Payments, kept for all of 1.x: same functions, and it calls /api/payments too.

Machine-authorized transfers

A draft inside your entity's authorization envelope (trusted payee, or a new one when you allow it, within limits) is sent to your enrolled authorizer endpoint as a payment.authorization_requested webhook carrying an authorization.id and a one-time nonce. Your system checks it against its own ledger, signs it and authorizes it with a different API key from the one that created it. examples/transfer_authorizer/ runs the whole flow. The transfer authorization docs have the full guide.

Setup, step by step

  1. Feature flags. Ask support to enable API transfers and transfer authorization (release_api_webhook_authorization) on your workspace.

  2. Two API keys. Key A creates drafts (transfers:write; beneficiaries:request_trust for step 6; entity:read, accounts:read, webhook_endpoints:read and beneficiaries:read for the preflight in step 7). Key B answers the challenges (transfers:authorize). The creating key is always refused with 403 same_key_forbidden, so keep key B and the signing secret on the system that holds your ledger. Bind key B to an IP allowlist: authorizing from a key without one is refused with 403 unrestricted_api_key. Bind key A to one too if you allow new payees (step 6): a draft to a new payee from a key without an allowlist goes to your approvers (unrestricted_api_key).

  3. A stable public URL. Your authorizer needs an HTTPS URL that never changes: an enrolled endpoint's URL is locked. Use a reserved ngrok domain (ngrok http --url=your-name.ngrok.app 4599) or a named Cloudflare tunnel. Never a free ngrok URL: it changes on every restart.

  4. Create the webhook endpoint at that URL:

    Manza.WebhookEndpoints.create(client, %{"url" => "https://your-name.ngrok.app/", "events" => ["transfer.executed"]})
  5. Enrol it in the app: Settings → Developer → Transfer approval. Pick the endpoint, the currency and both limits, confirm with your two-factor code, and copy the signing secret: it is shown once.

  6. Allow new payees, or trust each payee. Turn on Approve transfers to new payees at enrolment (or later, same page, with your two-factor code) and set the Limit per transfer to a new payee. Your authorizer may then approve a transfer to bank details your workspace never trusted, up to that limit, and its signature trusts them: the webhook's payment.payee_trusted is false for such a transfer. Bank details whose trust was revoked, or that were edited after trusting, still go to your approvers. Without the option, an approver marks the beneficiary's bank account as trusted from the Beneficiaries list, or you ask them to:

    Manza.PayeeTrustRequests.create(client, ["01a1..."])

    "trusted" on Manza.Beneficiaries.get_external_account/3 says which it is. The API does not show whether the option is on.

  7. Run the preflight, then the example (from examples/transfer_authorizer/, after mix deps.get and filling in .env): mix preflight checks what the API shows of steps 1–6 and prints a fix for each that fails. What it cannot see, check by hand: that key B is bound to an IP allowlist, and, for a payee not trusted yet, that Approve transfers to new payees is on and key A is bound to an IP allowlist too (it warns, it does not fail). Then mix authorizer in one terminal, mix create_transfer in another.

  8. Troubleshooting: a draft that goes to your approvers instead has an "authorization" with status "refused" and a "refusal_reason" (mix create_transfer prints it in words); Manza.TransferAuthorizer.get/1 shows a suspension and its "suspension_reason". An authorize answered 409 authorization_busy (a bank-detail edit, trust or trust revocation on the same payee was in flight) is safe to retry. The reasons, and an API error's type, are in the transfer authorization docs.

Verifying the webhook

Pass the raw request body, never re-encoded JSON: read it before any Plug.Parsers. construct_event/4 reads X-Manza-Signature and X-Manza-Timestamp (any case, a map or Plug's conn.req_headers, the CGI HTTP_X_MANZA_* form, or the legacy X-Zazu-* names), checks the signature in constant time and the timestamp within 5 minutes, and returns the parsed event (a string-keyed map):

{:ok, body, conn} = Plug.Conn.read_body(conn)

case Manza.Webhook.construct_event(body, conn.req_headers, signing_secret) do
  {:ok, event} -> handle(event)
  {:error, %ArgumentError{message: message}} -> Plug.Conn.send_resp(conn, 401, message)
end

construct_event!/4 returns the event or raises ArgumentError; Manza.Webhook.verify?(payload, signature, timestamp, secret) returns a boolean. All accept tolerance: nil to skip the timestamp check. Use it only to replay a captured delivery in a local tool or test, never on a live endpoint: without the check, anyone holding a captured delivery can replay it.

Signing the challenge

Sign the draft from your own record of it with the endpoint's signing secret, not from the webhook's signature_input (it is there only to compare against):

payee = Manza.TransferAuthorization.payee_for(external_account_id: draft["external_account_id"])

# `amount` must be the API's decimal string, e.g. "2500.0".
input =
  Manza.TransferAuthorization.signature_input(
    draft["id"], webhook["data"]["authorization"]["nonce"], draft["amount"],
    draft["currency_code"], draft["account_id"], payee, draft["client_reference"]
  )

signature = Manza.TransferAuthorization.sign(signing_secret, input)

authorizer = Manza.new!(api_key: System.fetch_env!("MANZA_AUTHORIZER_API_KEY"))
{:ok, _} = Manza.Payments.authorize(authorizer, draft["id"], webhook["data"]["authorization"]["id"], signature)
# or: Manza.Payments.decline(authorizer, draft["id"], webhook["data"]["authorization"]["id"], "reason")

A wrong signature returns {:error, %Manza.Error{kind: :validation, type: "invalid_signature"}}. Five on one challenge send the draft to your in-app approvers; five in a row suspend the authorizer. A blank signature returns {:error, %Manza.ConfigurationError{}} without calling the API (the server counts a missing signature as a failed attempt). create/2 accepts an optional client_reference (unique per entity, at most 128 characters); a duplicate returns {:error, %Manza.Error{kind: :conflict}} whose payment_id names the existing draft.

Response shape

Response bodies are returned as-is from the API — snake_case string-keyed maps in response.body, no struct mapping. The same shape ships across every Manza SDK (Ruby, TypeScript, Python, Go, ...) so the cassette contract is one-to-one.

List endpoints return a Manza.Page (data, has_more, next_cursor); Manza.Page.next/1 fetches the following page (nil on the last one). Page size is capped at 100 records.

Errors

Non-2xx responses come back as {:error, %Manza.Error{}} with status, kind (:authentication, :forbidden, :not_found, :validation for 400 and 422, :conflict for 409, :rate_limit, :server, :api), the API's type/message/param, and the request_id. Rate limits carry retry_after; conflicts carry payment_id. Transport failures are {:error, %Manza.ConnectionError{}}; invalid config is {:error, %Manza.ConfigurationError{}}.

Tests

Tests replay the canonical cassettes recorded by manza-ruby. The cassettes are downloaded from the Ruby SDK's release tarball and served from a Bypass server. Same interactions, same assertions, every language.

scripts/fetch-cassettes.sh
mix test

The SDK family

SDK Repository Install
Ruby (reference implementation, records the cassettes) getmanza/manza-ruby gem "manza"
TypeScript / JavaScript getmanza/manza-ts npm install @getmanza/sdk
Python getmanza/manza-python pip install manza
Go getmanza/manza-go go get github.com/getmanza/manza-go
PHP getmanza/manza-php composer require manza/manza-php
Rust getmanza/manza-rust cargo add manza
Crystal getmanza/manza-crystal shard manza (github: getmanza/manza-crystal)
Elixir getmanza/manza-elixir (this repo) {:manza, "~> 1.0"}
CLI getmanza/cli npm install -g @getzazu/cli or brew install getmanza/tap/zazu