# Zetus SPEI API - full developer documentation Generated by developer-docs/scripts/build-llms-full.mjs. Pages first, then the API reference appendix. --- # Zetus SPEI API - developer documentation Collect SPEI transfers on your own CLABEs: create a CLABE per customer, get notified when money arrives, and confirm it before you apply it. Request outgoing SPEI payments that two people of your organisation approve. Status: **the sandbox is the environment to integrate against now**: API `https://sandbox.zenit.zetuspay.com.mx`, token endpoint `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token`, docs `https://docs.sandbox.zenit.zetuspay.com.mx` ([use the sandbox](how-to/use-the-sandbox.md)). Production is not live yet: API `https://api.zenit.zetuspay.com.mx` (to be confirmed), identity provider `https://idp.zetuspay.com.mx` (token endpoint `https://idp.zetuspay.com.mx/oauth2/token`, discovery `https://idp.zetuspay.com.mx/.well-known/openid-configuration`). At go-live you change the token URL, the API URL and the certificate. ## Map | You want to... | Read | |---|---| | Make your first call | [tutorials/quickstart.md](tutorials/quickstart.md) | | Use the sandbox (trigger deposits, approve payments, force outcomes) | [how-to/use-the-sandbox.md](how-to/use-the-sandbox.md) | | Get a client certificate | [how-to/certificate-csr.md](how-to/certificate-csr.md) | | Get and cache an access token | [how-to/get-a-token.md](how-to/get-a-token.md) | | Retry safely after a timeout | [how-to/idempotency-and-retries.md](how-to/idempotency-and-retries.md) | | Walk a list | [how-to/pagination.md](how-to/pagination.md) | | Receive and verify webhooks | [how-to/verify-webhooks.md](how-to/verify-webhooks.md) | | Handle errors | [how-to/errors.md](how-to/errors.md) | | Send payments | [how-to/send-payments.md](how-to/send-payments.md) | | Go to production | [how-to/go-live.md](how-to/go-live.md) | | Understand when money is real | [explanation/incoming-transfer-lifecycle.md](explanation/incoming-transfer-lifecycle.md) | | Understand the 18:00 cut | [explanation/operation-date-and-cutoff.md](explanation/operation-date-and-cutoff.md) | | Understand when a payment is final | [explanation/payment-lifecycle.md](explanation/payment-lifecycle.md) | | Look up an endpoint or field | [reference/README.md](reference/README.md) | ## Conventions in one minute - Auth: mutual TLS (client certificate) **plus** `Authorization: Bearer ` bound to that certificate. - Success: `{"data": ..., "meta": {"traceId": "..."}}`. Errors: `{"error": {"code", "message", "details", "traceId"}}`. - Money is an integer in centavos with `currency: "MXN"` (`150050` = $1,500.50). Dates are UTC ISO 8601. - Every write needs a `reference` (your idempotency key). ## Other artifacts - `../openapi/openapi.json` - the OpenAPI 3.1 contract. - `codes.json` - error catalogue. - `bruno/` - Bruno collection generated from the contract. - `sdk/` - TypeScript types generated from the contract. - `llms.txt`, `llms-full.txt` - the docs for LLM tools. - `scripts/` - generators ([scripts/README.md](scripts/README.md)). --- # Quickstart: from sandbox credentials to your first call You will get a token, list incoming transfers and create a CLABE. Work against the sandbox: API `https://sandbox.zenit.zetuspay.com.mx`, token endpoint `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token` (both are also in your `sandbox.json`). The sandbox and how to trigger the network's side: [use-the-sandbox.md](../how-to/use-the-sandbox.md). ## What you receive The sandbox is the same API with a test tenant and a simulated network. Credentials arrive through **two separate channels**: the certificate issued from the CSR you sent ([how-to/certificate-csr.md](../how-to/certificate-csr.md)), and the client id. In the sandbox the token URL is `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token`; in production it will be `https://idp.zetuspay.com.mx/oauth2/token`. The sandbox hands out one file, `sandbox.json`: ```json { "apiBaseUrl": "", "tokenUrl": "", "clientId": "", "clientCertificate": "/absolute/path/to/client.crt", "clientKey": "client.key (PEM, or inline PEM text)", "serverCaBundle": "/absolute/path/to/ca.pem", "scopes": ["spei-api/accounts.read", "spei-api/accounts.write", "spei-api/transfers.read"] } ``` | Field | Meaning | |---|---| | `apiBaseUrl` | Base URL of the API for this sandbox | | `tokenUrl` | Token endpoint: `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token` in the sandbox, `https://idp.zetuspay.com.mx/oauth2/token` in production | | `clientId` | Your OAuth2 client id | | `clientCertificate`, `clientKey` | Your client certificate and its private key | | `serverCaBundle` | CAs to trust when validating the server certificates (the sandbox's are publicly trusted: your system store, unless a `ca.pem` is handed out) | | `scopes` | Scopes your client may request | Treat the file as a secret: it contains a private key. Do not commit it. Set up the shell (adjust to your files): ```bash export API= export TOKEN_URL= export CLIENT_ID= # files: client.crt, client.key, ca.pem ``` ## 1. Get a token (curl) The token request goes over the **same mTLS connection** (`tls_client_auth`, RFC 8705): the identity provider binds the token to your certificate. ```bash curl -sS --cert client.crt --key client.key --cacert ca.pem \ -d grant_type=client_credentials \ -d client_id="$CLIENT_ID" \ -d scope="spei-api/accounts.read spei-api/accounts.write spei-api/transfers.read spei-api/balance.read spei-api/webhooks.manage" \ "$TOKEN_URL" ``` The response has `access_token` (ES256 JWT, 15 minutes). Keep it in a variable: ```bash export TOKEN= ``` ## 2. List incoming transfers ```bash curl -sS --cert client.crt --key client.key --cacert ca.pem \ -H "Authorization: Bearer $TOKEN" \ "$API/v1/incoming-transfers?limit=10" ``` ```json {"data": [], "meta": {"traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "nextCursor": null}} ``` An empty list is fine for a fresh tenant. ## 3. Create a CLABE Every write needs a `reference`: your idempotency key ([how-to/idempotency-and-retries.md](../how-to/idempotency-and-retries.md)). ```bash curl -sS --cert client.crt --key client.key --cacert ca.pem \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"reference":"clabe-customer-8812","ownerRef":"customer-8812","holder":{"type":"natural","name":"JUAN","firstSurname":"PEREZ","secondSurname":"LOPEZ","taxId":"PELJ800101AB1","dateOfRecord":"1980-01-01","country":"MX"}}' \ "$API/v1/collection-accounts" ``` Every CLABE is registered with the network under its `holder` (the person or company it belongs to): names in upper case, letters A-Z and single spaces only (no accents, no Ñ, no punctuation), the official RFC or CURP, the birth or incorporation date, and the country (only `MX` for now). The holder is stored apart and never returned. `201` with the CLABE; its `status` starts as `pending_registration` while the network reviews the holder (up to 48 hours), then becomes `active` (`collection_account.activated`) or `rejected` (`collection_account.rejected`, final: create another CLABE with corrected data). Money sent to it before `active` is not yours yet: it waits in suspense. Repeat the exact same request: you get the same answer with the header `Idempotent-Replayed: true`. ## The same in TypeScript (Node 22+) The file paths in `sandbox.json` are absolute PEM files. The example uses top-level `await`, so run it as an ES module: put `"type": "module"` in your `package.json` (or name the file `.mts`) and run it with `npx tsx quickstart.ts`. ```ts import { readFileSync } from "node:fs"; import { Agent } from "undici"; // npm i undici const API = process.env.API!; const TOKEN_URL = process.env.TOKEN_URL!; const CLIENT_ID = process.env.CLIENT_ID!; const dispatcher = new Agent({ connect: { cert: readFileSync("client.crt"), key: readFileSync("client.key"), ca: readFileSync("ca.pem"), }, }); const tokenRes = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: CLIENT_ID, scope: "spei-api/accounts.read spei-api/accounts.write spei-api/transfers.read", }), dispatcher, } as RequestInit); const { access_token } = (await tokenRes.json()) as { access_token: string }; async function call(path: string, init: RequestInit = {}) { const res = await fetch(`${API}${path}`, { ...init, headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json", ...init.headers }, dispatcher, } as RequestInit); return { status: res.status, body: await res.json() }; } console.log(await call("/v1/incoming-transfers?limit=10")); console.log( await call("/v1/collection-accounts", { method: "POST", body: JSON.stringify({ reference: "clabe-customer-8812", ownerRef: "customer-8812", holder: { type: "natural", name: "JUAN", firstSurname: "PEREZ", secondSurname: "LOPEZ", taxId: "PELJ800101AB1", dateOfRecord: "1980-01-01", country: "MX", }, }), }), ); ``` Note: `dispatcher` is an undici option that Node's built-in `fetch` accepts; the `as RequestInit` cast is only for TypeScript. ## Troubleshooting - `401 unauthenticated` always, with no reason: wrong or expired token, token issued for a different certificate, or no/invalid client certificate. Fetch a new token with the certificate you call with. - `403 forbidden`: the token lacks the scope for that operation. - Connection fails during the TLS handshake: check `--cacert` and that the certificate and key match. Quote the `traceId` (or the `traceparent` response header) when you contact support. Next: [go-live](../how-to/go-live.md), [verify webhooks](../how-to/verify-webhooks.md). --- # Generate a client key and CSR Your client certificate is issued by Zetus's client CA. You generate the private key yourself and send only a certificate signing request (CSR). **The private key never leaves your systems and is never sent to anyone.** ## Generate the key and CSR EC P-256 key: ```bash openssl ecparam -name prime256v1 -genkey -noout -out client.key chmod 600 client.key ``` CSR (the subject is a label for you; the identity of your client is established by the issued certificate and your client id, not by what you type here): ```bash openssl req -new -key client.key -sha256 \ -subj "/O=/CN=" \ -out client.csr ``` Check it: ```bash openssl req -in client.csr -noout -text -verify ``` ## What to send - Send **`client.csr`** only. - Do not send `client.key`. If you ever sent it anywhere, generate a new pair and ask for revocation. You receive the issued certificate (`client.crt`) and the CA bundle to trust the server (`ca.pem`). Credentials arrive through separate channels from the client id and token URL. Confirm the certificate matches your key: ```bash diff <(openssl x509 -in client.crt -noout -pubkey) <(openssl pkey -in client.key -pubout) && echo match openssl x509 -in client.crt -noout -subject -dates -fingerprint -sha256 ``` The SHA-256 fingerprint is what your access tokens are bound to (`cnf.x5t#S256`). ## Rotation Certificates live at most one year. Rotate with a 30-day overlap: 1. At least 30 days before expiry, generate a **new** key and CSR and send the CSR. 2. Install the new certificate and key next to the old ones. Tokens are bound to one certificate, so request a token with the certificate you use for the calls. 3. Switch your clients to the new certificate; verify calls succeed. 4. Ask for the old certificate to be retired (or let it expire). ## Revocation If a key may be exposed (leaked file, departed employee, compromised host), tell Zetus immediately through your support channel so the certificate is revoked, then rotate: new key, new CSR. Tokens bound to a revoked certificate stop being useful with it. The contact channel is handed out with your credentials (to be confirmed). --- # Errors ## Envelope ```json {"error": {"code": "invalid_request", "message": "...", "details": {"reference": "must match ^[A-Za-z0-9._-]{1,64}$"}, "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"}} ``` - Branch on `code`, never on `message` (messages may change). - `details` is an object of per-field reasons for `invalid_request` (unknown fields are rejected too). - `traceId` (also the `traceparent` response header) is what support needs. Send your own W3C `traceparent` request header to correlate with your logs. ## Status matrix | Status | Meaning | What to do | |---|---|---| | 400 | Invalid body, malformed JSON, or body over 8 KB | Fix the request; do not retry | | 401 | Any authentication failure (no reason given) | New token; check the certificate | | 403 | Token lacks the scope | Request the right scope | | 404 | Not found, or belongs to another tenant (indistinguishable) | Check the id | | 409 | `idempotency_conflict`, `invalid_state`, or a payment rule (`beneficiary_*`, `limit_exceeded`, `insufficient_funds`) | Do not retry as is; the payment ones can succeed later (see `retryable`) | | 429 | Rate limited | Wait `Retry-After`, then retry | | 500 / 503 | Server side | Retry with backoff, same `reference` | ## Catalogue Generated from [`codes.json`](../codes.json). | Code | Status | Retryable | Message | |---|---|---|---| | `invalid_request` | 400 | no | The request has invalid or unknown fields. | | `malformed_body` | 400 | no | The body is not valid JSON. | | `payload_too_large` | 400 | no | The body exceeds the maximum size of 8 KB. | | `unauthenticated` | 401 | no | Missing or invalid credentials. | | `forbidden` | 403 | no | The token does not grant this operation: missing scope, or the wrong kind of principal. | | `not_found` | 404 | no | The resource does not exist. | | `idempotency_conflict` | 409 | no | This reference was already used with a different request. | | `invalid_state` | 409 | no | The resource is not in a state that allows this operation. | | `beneficiary_not_verified` | 409 | yes | The beneficiary has not been verified by a person yet. | | `beneficiary_cooling_down` | 409 | yes | The beneficiary was verified less than 24 hours ago. Retry after usableFrom. | | `limit_exceeded` | 409 | yes | The amount exceeds the per-order or the daily limit of your tenant. | | `insufficient_funds` | 409 | yes | Your verified balance does not cover this payment. | | `approval_not_allowed` | 403 | no | This person cannot approve or verify it: requester, creator, or already approved by them. | | `step_up_required` | 401 | no | A fresh multi-factor authentication (5 minutes or less) bound to this exact operation is required. | | `rate_limited` | 429 | yes | Too many requests. Wait for Retry-After seconds. | | `internal_error` | 500 | yes | Unexpected error. Retry later; if it persists, contact support with the traceId. | | `service_unavailable` | 503 | yes | A dependency is unavailable. Retry later. | Retry rhythm and the use of `reference`: [idempotency-and-retries.md](idempotency-and-retries.md). --- # Get and cache an access token Every call needs `Authorization: Bearer ` in addition to the client certificate. ## Request OAuth2 `client_credentials` against the identity provider, over the **same mTLS connection** you use for the API (`tls_client_auth`, RFC 8705). The token URL is `https://idp.zetuspay.com.mx/oauth2/token` (issuer `https://idp.zetuspay.com.mx`; discovery document at `https://idp.zetuspay.com.mx/.well-known/openid-configuration`). In the sandbox the token URL is `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token` (issuer `https://auth.sandbox.zenit.zetuspay.com.mx`), also in your `sandbox.json`; tokens there have the same shape and are bound to your sandbox certificate. ```bash curl -sS --cert client.crt --key client.key --cacert ca.pem \ -d grant_type=client_credentials \ -d client_id="$CLIENT_ID" \ -d scope="spei-api/accounts.read spei-api/transfers.read" \ "https://idp.zetuspay.com.mx/oauth2/token" ``` ## What the token is - ES256 JWT, valid 15 minutes, `aud=spei-api`. - `typ`: who the token speaks for. Your system's tokens are `partner`; they work on every route except the approval routes, which only accept `human` tokens issued to people through the console. A token without `typ`, or with a value this API does not know, is `401`; a `service` token is `403`. - `tid`: your tenant. - `scope`: like `spei-api/accounts.read`. - `cnf.x5t#S256`: SHA-256 thumbprint of your certificate. **A token only works with the certificate it was issued for.** ## Scopes | Scope | Allows | |---|---| | `spei-api/accounts.read` | Read CLABEs | | `spei-api/accounts.write` | Create, update and close CLABEs | | `spei-api/transfers.read` | Read incoming transfers | | `spei-api/balance.read` | Read the balance | | `spei-api/webhooks.manage` | Manage endpoints, deliveries, test events | | `spei-api/payments.request` | Propose beneficiaries, request and cancel payments, read your limits | | `spei-api/payments.approve` | Verify beneficiaries and approve payments. **Never granted to your system**: only people, with `typ=human`, through the console | `GET /v1/events` accepts any one of the read scopes or `webhooks.manage`. Request only what you need. ## Cache it There is no refresh token. Cache the token and request a new one when fewer than 2 minutes remain (decode `exp`, or track `expires_in` from the response). Do not request one per call. ```ts let cached: { token: string; exp: number } | undefined; export async function getToken(fetchToken: () => Promise): Promise { const now = Math.floor(Date.now() / 1000); if (cached && cached.exp - now > 120) return cached.token; const token = await fetchToken(); const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); cached = { token, exp: payload.exp }; return token; } ``` ## Failures Every authentication failure is `401 unauthenticated` with no reason (by design). Checklist: token expired; token issued for another certificate (for example after a rotation); no or invalid client certificate; wrong audience. A missing scope is `403 forbidden`. On a 401, fetch a new token once and retry; if it fails again, stop and check the certificate. --- # Go to production checklist The production host `https://api.zenit.zetuspay.com.mx` is not live yet (to be confirmed). The steps below are the plan; confirm each with Zetus when it applies. From the sandbox you change exactly three things: the token URL (`https://idp.zetuspay.com.mx/oauth2/token`), the API base URL and the client certificate; the `/sandbox/*` routes do not exist in production. ## Before certification - [ ] Sandbox flows work end to end: token, create CLABE, receive a transfer, read it, apply it only when `verification.verified` is true. - [ ] Token cached and renewed with 2 minutes to spare; no token per call. - [ ] Every write sends a `reference` stored before the call; retries reuse it ([idempotency](idempotency-and-retries.md)). - [ ] Timeouts handled with `GET /v1/collection-accounts/by-reference/{reference}`. - [ ] Money kept as integer centavos; never floats. - [ ] 429 respects `Retry-After`; you watch `RateLimit`. - [ ] You log the `traceId` of every response and error. - [ ] Pagination handled; `GET /v1/events?after=` catch-up job exists. ## Webhooks - [ ] Public HTTPS endpoint with a valid certificate. - [ ] Signature verified on the raw body, 5-minute tolerance, dedupe by `webhook-id`, answer 2xx fast ([guide](verify-webhooks.md)). - [ ] You never apply money from a webhook alone: you read `GET /v1/incoming-transfers/{id}` first. - [ ] `received`, `disputed` and `unidentified` are never applied; `returned` reverses what you applied ([lifecycle](../explanation/incoming-transfer-lifecycle.md)). - [ ] A monitor on `GET /v1/webhook-deliveries?status=failed` and a procedure to retry. - [ ] Secret rotation rehearsed. ## Certification Zetus certifies by `traceId`: for each scenario (create CLABE, close CLABE, verified transfer, disputed, returned, unidentified handling, timeout recovery by reference, webhook retry), run it in the sandbox and send the `traceId` of the calls as evidence. The exact scenario list and the channel are confirmed by Zetus (to be confirmed). ## Production - [ ] Production client certificate issued from a new CSR ([guide](certificate-csr.md)); the sandbox certificate is not reused. Private key stored in your secret manager. - [ ] Production client id received through a channel different from the certificate; token URL `https://idp.zetuspay.com.mx/oauth2/token`. - [ ] Webhook endpoint registered in production; the secret stored. - [ ] Certificate rotation calendar (maximum 1 year, start 30 days before). - [ ] Alerting for 401 spikes, 5xx, failed deliveries and ages of unverified transfers. - [ ] Support contact and `traceId` procedure known (contact channel to be confirmed). --- # Idempotency and retries ## `reference` Every write (POST, and PATCH with effect) requires `reference` in the body: `^[A-Za-z0-9._-]{1,64}$`, unique per tenant. This API has no `Idempotency-Key` header; `reference` is its equivalent. - Same `reference` + same body: same answer (same status), header `Idempotent-Replayed: true`, no second effect. - Same `reference` + different body: `409 idempotency_conflict`. Generate the reference from your own business identifier (for example `clabe-customer-8812`) and store it before sending, so a retry reuses it. `POST /v1/webhook-endpoints/{id}/test` needs no reference. ## After a timeout You do not know whether the request arrived. Ask: ```bash GET /v1/collection-accounts/by-reference/{reference} GET /v1/beneficiaries/by-reference/{reference} GET /v1/payment-requests/by-reference/{reference} ``` - `200`: it arrived; use the result. - `404`: it never arrived; resend with the **same** reference and body. Resending the same request is always safe because of the rules above, so you can also simply retry. ## What to retry Retry only retryable errors: network failures and timeouts, `429`, `500`, `503` (see [errors.md](errors.md)). Do not retry `400`, `401`, `403`, `404`, `409`: fix the request first. On `429`, wait for `Retry-After`. ## Rhythm Wait 2, 5, 10 and 30 seconds, then every minute, up to 15 minutes in total; then stop and alert someone. Keep the same `reference` throughout. ```ts const delays = [2, 5, 10, 30, ...Array(15).fill(60)]; // seconds, ~15 min async function withRetries(send: () => Promise): Promise { for (let i = 0; ; i++) { try { const res = await send(); if (![429, 500, 503].includes(res.status)) return res; if (i >= delays.length) return res; const retryAfter = Number(res.headers.get("retry-after")); await sleep((retryAfter || delays[i]) * 1000); } catch (e) { if (i >= delays.length) throw e; await sleep(delays[i] * 1000); } } } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); ``` ## Rate limits Per client: `RateLimit-Policy: "consumer";q=300;w=60` (300 requests per 60 s) and `RateLimit: "consumer";r=;t=`. Slow down as `r` approaches 0. --- # Pagination List endpoints return newest first, with a cursor. ```json {"data": [ ... ], "meta": {"traceId": "...", "nextCursor": "eyJpZCI6..."}} ``` - `limit`: 1-200, default 50. - `cursor`: pass the previous `meta.nextCursor`. It is opaque; do not build or parse it. - `nextCursor` is `null` on the last page. - Keep the same filters on every page. ```ts async function* all(path: string, call: (p: string) => Promise) { let cursor: string | null = null; do { const sep = path.includes("?") ? "&" : "?"; const page = await call(`${path}${sep}limit=200${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`); yield* page.data; cursor = page.meta.nextCursor; } while (cursor); } ``` ## Exception: the event log `GET /v1/events` is ordered **oldest first** and uses `after=` instead of a cursor. To catch up after downtime, store the last event id you processed and call `GET /v1/events?after=`; while `meta.nextCursor` is not null, call again with `after=` (it is the last event id of the page). When it is null you are up to date. Filter with `type`. Other filters: incoming transfers accept `status`, `operationDate`, `clabe`, `trackingKey`, `numericReference`; deliveries accept `status` (`pending`, `in_flight`, `succeeded`, `failed`). --- # Send payments Your system **requests** outgoing SPEI payments. It never approves them: two different people of your organisation approve each one in the console, each with a fresh multi-factor check bound to that exact payment. Then we send it and tell you how it ends. You need the scope `spei-api/payments.request`. All amounts are integer centavos with `currency: "MXN"`; every write carries a `reference` (see [idempotency-and-retries.md](idempotency-and-retries.md)). ## 1. Propose the beneficiary (once per account) ```bash POST /v1/beneficiaries {"reference": "ben-proveedor-17", "name": "PROVEEDOR DEMO SA DE CV", "clabe": "012180001234567899", "institution": 40012, "taxId": "PDE010101AB1"} ``` - `201` with `status: "pending_verification"`. - `clabe` must pass its check digit; `institution` is the 5-digit SPEI key of the receiving bank and must end with the CLABE's first three digits (`012…` → `40012`). Otherwise `400 invalid_request`. - `taxId` is optional (we send `ND` without it). A person of your organisation, **not the one who proposed it**, verifies it in the console. From that moment the beneficiary is usable after **24 hours** (`usableFrom` in the answer of `GET /v1/beneficiaries/{id}`). A beneficiary never changes: a different account or holder is a new beneficiary, verified again. ## 2. Check your limits ```bash GET /v1/limits ``` ```json {"data": {"perOrder": 5000000, "daily": 20000000, "usedToday": 150050, "currency": "MXN", "operationDate": "2026-10-14"}} ``` `perOrder: 0` means payments are not enabled for your organisation yet. The daily limit counts against the SPEI **operation date** (it changes at 18:00 Mexico City time and on business days; see [operation-date-and-cutoff.md](../explanation/operation-date-and-cutoff.md)). ## 3. Request the payment ```bash POST /v1/payment-requests {"reference": "pay-invoice-A-1029", "beneficiaryId": "ben_068F2V4K8M1N3P5Q7R9S1T3V5W", "amount": 150050, "currency": "MXN", "concept": "PAGO FACTURA A-1029", "numericReference": 1029} ``` - `202` with `status: "pending_approval"` and the `trackingKey` (SPEI's *clave de rastreo*) assigned **now**. It never changes, and repeating the request with the same `reference` and body returns the same payment and the same key. - `concept`: 1–40 letters, digits, spaces and `. , : / _ -`, no accents. `numericReference`: 0–9999999. | Refusal | Code | What to do | |---|---|---| | Beneficiary not verified yet | `409 beneficiary_not_verified` | Ask your team to verify it | | Verified less than 24 h ago | `409 beneficiary_cooling_down` | Retry after `error.details.usableFrom` | | Over the per-order limit | `409 limit_exceeded` (`details.limit = per_order`) | Split it or ask to raise the limit | | Same `reference`, other body | `409 idempotency_conflict` | Use a new reference for a new payment | ## 4. Wait for the approvals Two people approve it in the console. The **second** approval applies the daily limit and your verified balance, so a request can stay `pending_approval` (with `approvals: 1`) when either is exhausted; it is not lost. Until it is sent you can cancel it: ```bash POST /v1/payment-requests/{id}/cancel {"reference": "cancel-pay-invoice-A-1029"} ``` A token of your system on the approval routes is always `403 forbidden`, whatever its scopes. ## 5. Follow it to the end Subscribe your webhook endpoint (see [verify-webhooks.md](verify-webhooks.md)); each event's `data` is the thin payment (`id`, `reference`, `status`, `trackingKey`, `amount`, `currency`). Read `GET /v1/payment-requests/{id}` for the rest. | Event | Meaning | Your move | |---|---|---| | `payment.pending_approval` | Requested | Nothing | | `payment.approved` | Two people approved; it fit the limits | Nothing | | `payment.submitted` | The network accepted it | Nothing | | `payment.settled` | Settled **provisionally**; the receiving bank can still return it for about 2 minutes | Do not mark it paid yet | | `payment.completed` | Final: money out | Mark it paid | | `payment.returned` | The receiving bank returned it; your balance was restored | Check the account with your supplier | | `payment.cancelled` | Cancelled before settlement | Nothing was paid | | `payment.failed` | Not sent, or refused by the network (`failureReason`); your balance was restored | Fix and request again with a **new** reference | After a timeout on your request, ask `GET /v1/payment-requests/by-reference/{reference}`: `404` means it never arrived, so resend it with the same reference. Never create a second payment to "retry" one you are unsure about. ## In the sandbox Payments work end to end in the sandbox. The console is not live, so two sandbox routes stand in for the people: `POST /sandbox/beneficiaries/{id}/verify` (the second person verifies; no 24 h wait in the sandbox) and `POST /sandbox/payment-requests/{id}/approve` (two people approve, each with a step-up bound to the order). The centavos of `amount` force outcomes of the simulated network: `…,01` refused (`payment.failed`), `…,03` late answer (settled later), `…,04` stays `submitted`, `…,07` settled and then returned (`payment.returned`), `…,08` cancelled. Details: [use-the-sandbox.md](use-the-sandbox.md). --- # Use the sandbox The sandbox is a complete installation of the API for integration: the same API, the same security (mutual TLS plus a token bound to your certificate), the same contract and the same webhooks as production. Behind it, the interbank network is **simulated**: no real money moves, and you trigger the network's side yourself (an incoming transfer, the outcome of a payment) through a few extra routes under `/sandbox`. | | URL | |---|---| | API | `https://sandbox.zenit.zetuspay.com.mx` | | Token endpoint | `https://auth.sandbox.zenit.zetuspay.com.mx/oauth2/token` | | Documentation | `https://docs.sandbox.zenit.zetuspay.com.mx` (this site; [API reference](../reference/README.md), `llms.txt`, `llms-full.txt`) | ## Get access 1. Generate your key and CSR ([certificate-csr.md](certificate-csr.md)) and send **only the CSR**. 2. You receive `client.crt` (issued by the sandbox client CA), `client-ca.pem` (that CA, for reference) and `sandbox.json` with the URLs, your client id and your scopes. Your key never leaves your systems. 3. Get a token with your certificate ([get-a-token.md](get-a-token.md)); every call to the API goes over mTLS with the same certificate. Every request to the API host and the token host needs your client certificate: without it, or with a certificate of another CA, the TLS handshake fails. The documentation host is public. ## What differs from production | | Sandbox | Production | |---|---|---| | Token endpoint and issuer | `https://auth.sandbox.zenit.zetuspay.com.mx` (a simulated identity provider) | `https://idp.zetuspay.com.mx` | | Client certificate | Issued by the **sandbox** client CA | A **new** certificate from a new CSR, issued by the production client CA. The sandbox certificate is never valid in production | | API host | `https://sandbox.zenit.zetuspay.com.mx` | `https://api.zenit.zetuspay.com.mx` (to be confirmed) | | Network | Simulated; outcomes forced by the amount (below) | Real | | Approvals of payments | `POST /sandbox/payment-requests/{id}/approve` approves as two simulated people | Two people of your organisation approve in the console, each with multi-factor step-up | | Beneficiary verification | `POST /sandbox/beneficiaries/{id}/verify`, usable at once | A second person verifies in the console; usable 24 hours later | | CLABE registration | Active a few seconds after creation | Up to 48 hours of review | | Timings | Verification within about 30 seconds | Within minutes | | `/sandbox/*` routes | Available | Do not exist | Token format, scopes, the certificate binding (`cnf.x5t#S256`), limits, errors, idempotency and webhook signatures are the same. At go-live you change three things: the token URL, the API base URL and the certificate ([go-live.md](go-live.md)). ## Trigger an incoming transfer ```bash curl -sS --cert client.crt --key client.key \ -H "Content-Type: application/json" \ -d '{"clabe":"","amount":150050,"payerName":"PAGADOR DE PRUEBA SA","concept":"FACTURA A-1029","numericReference":1029}' \ "$API/sandbox/deposits" ``` `202` with the `trackingKey` the network assigned and the `expectedStatus`. The transfer appears in `GET /v1/incoming-transfers?trackingKey=…` as `received` at once, and moves on when the network confirms it, exactly as in production: then `incoming_transfer.verified` (or another event) is delivered to your webhook endpoints. `payerName` and `concept`: up to 40 letters, digits, spaces and `.,-`. Only your own CLABEs are accepted (any other is `404`). The **centavos of the amount** decide what the network does: | Amount ends in | Outcome | |---|---| | `…,05` | The network does not know the transfer: `disputed` after its lookups (never applied) | | `…,06` | The network confirms another amount: `disputed` | | `…,02` | The network's query quota is exhausted for a while: stays `received` and is retried (other lookups wait too) | | `…,04` | The network answers errors: stays `received` and is retried | | anything else | `verified` | A transfer to a CLABE that is `closed` is `returned` by the receiver; one to a CLABE still `pending_registration` waits in suspense (`unidentified`). ## CLABEs `POST /v1/collection-accounts` as in production. The simulated review accepts the holder a few seconds later (`collection_account.activated`); a holder whose name contains `RECHAZO` is rejected (`collection_account.rejected`). ## Payments 1. Propose a beneficiary: `POST /v1/beneficiaries` ([send-payments.md](send-payments.md)). 2. Verify it as the second person would: `POST /sandbox/beneficiaries/{id}/verify` (no body). It answers the beneficiary, `verified` and usable now. 3. Request the payment: `POST /v1/payment-requests` → `202 pending_approval`. 4. Approve it as the two people would: `POST /sandbox/payment-requests/{id}/approve` (no body). Each simulated person approves with a fresh step-up bound to the order's `approvalChallenge`; the limits and your balance are checked at the second approval, as in production (a refusal comes back with the API's error). To decline instead: `POST /sandbox/payment-requests/{id}/reject` (the request ends `cancelled`). 5. Follow it with `GET /v1/payment-requests/{id}` and the `payment.*` webhooks: `approved` → `submitted` → `settled` → `completed` (about two minutes after settlement, the return window). The centavos of the amount decide what the network does with the order: | Amount ends in | Outcome | |---|---| | `…,01` | Refused when sent: `failed` (`payment.failed`), your balance restored | | `…,03` | The network's answer is late: found later and `settled`, then `completed` | | `…,04` | The network answers errors: stays `submitted` and is retried | | `…,07` | `settled`, then returned by the receiving bank: `returned` (`payment.returned`) | | `…,08` | Cancelled by the network: `cancelled` | | anything else | `settled`, then `completed` | To force an outcome on a payment already sent: `POST /sandbox/payment-requests/{id}/outcome` with `{"outcome":"settled"}`, `{"outcome":"returned"}` (also after `completed`: a late return) or `{"outcome":"cancelled"}`. Your limits in the sandbox: $100,000.00 per payment and $1,000,000.00 per operation date. You can only pay out what has been verified: trigger deposits first. ## Webhooks The sandbox delivers to public HTTPS endpoints only, as production does. Verify each delivery as in [verify-webhooks.md](verify-webhooks.md); `POST /v1/webhook-endpoints/{id}/test` sends a signed test at once. `GET /sandbox` lists these routes and both outcome tables. ## Data The sandbox is for test data only: never real customers' names, tax ids or accounts. Data may be erased when the sandbox is rebuilt. --- # Receive and verify webhooks Webhooks follow the [Standard Webhooks](https://www.standardwebhooks.com) spec. They tell you something happened; they are **thin** and never enough on their own to apply money. ## Create an endpoint ``` POST /v1/webhook-endpoints (scope spei-api/webhooks.manage) {"reference": "wh-main-1", "url": "https://example.com/webhooks/spei"} ``` In production the URL must be HTTPS on a public address (private, loopback and plain-http destinations are refused with `400 invalid_request`, `details.url`). The sandbox applies the same rule: give it a public HTTPS endpoint (a tunnel to your machine works). The answer contains the signing secret `whsec_...`. **It is shown only in the response of create and rotate** (and in idempotent replays of them). Store it in your secret manager. Send a signed test with `POST /v1/webhook-endpoints/{id}/test` (no reference needed). ## What you receive Headers: | Header | Meaning | |---|---| | `webhook-id` | The event id (`evt_...`). Deduplicate with it. | | `webhook-timestamp` | Unix seconds when signed. Reject if older than 5 minutes. | | `webhook-signature` | Space-separated list of `v1,`: HMAC-SHA256 over `id.timestamp.body`. Two signatures for 24 h after a rotation. | Body (thin event): ```json {"id": "evt_068F2T5M7N9P1Q3R5S7T9V1W3X", "type": "incoming_transfer.verified", "timestamp": "2026-10-08T17:05:12Z", "data": {"id": "itr_...", "status": "verified", "amount": 150050, "currency": "MXN", "clabe": "...", "operationDate": "2026-10-08", "trackingKey": "..."}} ``` Types: `incoming_transfer.verified`, `incoming_transfer.unidentified`, `incoming_transfer.disputed`, `incoming_transfer.returned`, `collection_account.activated`, `collection_account.rejected`, `collection_account.closed`, `collection_account.deregistered`, `webhook.test`. See [the lifecycle](../explanation/incoming-transfer-lifecycle.md) for what each means. ## Verify with `standardwebhooks` ```bash npm i standardwebhooks ``` ```ts import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.WEBHOOK_SECRET!); // "whsec_..." as delivered // rawBody: the exact bytes received, as string or Buffer. NOT re-serialized JSON. const event = wh.verify(rawBody, { "webhook-id": headers["webhook-id"], "webhook-timestamp": headers["webhook-timestamp"], "webhook-signature": headers["webhook-signature"], }) as { id: string; type: string; data: any }; ``` Checked against `standardwebhooks@1.1.1`: the `Webhook` constructor accepts the secret **with** the `whsec_` prefix (it strips it and base64-decodes the rest), so pass the secret exactly as the API gave it. `verify` throws `WebhookVerificationError` on missing headers, a timestamp outside a 5-minute tolerance, or no matching `v1` signature; it checks every signature in the list, so rotation needs no special code. ## Express example (raw body) ```ts import express from "express"; import { Webhook, WebhookVerificationError } from "standardwebhooks"; const app = express(); const wh = new Webhook(process.env.WEBHOOK_SECRET!); app.post("/webhooks/spei", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body.toString("utf8"); // express.raw gives a Buffer let event: { id: string; type: string; data: { id: string } }; try { event = wh.verify(rawBody, req.headers as Record) as typeof event; } catch (e) { if (e instanceof WebhookVerificationError) return res.sendStatus(400); throw e; } // Dedupe by webhook-id (= event.id): at-least-once delivery means repeats. if (await alreadyProcessed(req.header("webhook-id")!)) return res.sendStatus(200); await enqueue(event); // process asynchronously res.sendStatus(200); // any 2xx stops the retries; answer fast }); ``` Do not mount `express.json()` before this route: it would consume and alter the body. ## What to do with an event 1. Verify the signature, then deduplicate by `webhook-id`. 2. Enqueue and answer 2xx quickly. 3. In the worker, call `GET /v1/incoming-transfers/{id}` and apply the money only if `verification.verified` is true. A webhook alone is never enough. ## Delivery At-least-once; retried with exponential backoff for at least 24 hours; then the delivery is `failed` and shows up in `GET /v1/webhook-deliveries?status=failed`. Retry one with `POST /v1/webhook-deliveries/{id}/retry` (needs a `reference`). After downtime, catch up with `GET /v1/events?after=` (oldest first). ## Rotate the secret `POST /v1/webhook-endpoints/{id}/rotate-secret` with a `reference`. The response carries the new `whsec_...`. For 24 hours deliveries carry two signatures (old and new), so deploy the new secret at your leisure; nothing breaks. After the overlap only the new secret verifies. `DELETE /v1/webhook-endpoints/{id}` disables an endpoint. ## Send a test event `POST /v1/webhook-endpoints/{id}/test` — no body and no `reference` needed (it changes nothing). We send a signed `webhook.test` event at once and answer `{"data":{"delivered":true,"statusCode":204,"eventId":"evt_…"}}` with what your endpoint replied. A disabled endpoint answers `409 invalid_state`. Verify that event exactly as any other. --- # Incoming transfer lifecycle An incoming transfer is a SPEI payment into one of your CLABEs. ## States | `status` | Meaning | Apply it? | |---|---|---| | `received` | Arrived, **not confirmed** | Never | | `verified` | Confirmed against the network (`verification.verified` is true) | Yes | | `disputed` | Does not match what the network confirms | Never | | `unidentified` | Confirmed, but the CLABE has no owner; tenants do not normally see these | n/a | | `returned` | The money went back to the payer | Reverse what you applied | ``` received --> verified --> returned \--> disputed unidentified (confirmed, no owner) ``` ## Why `received` is not `verified` A message announcing a payment is not proof of money. Zetus confirms each transfer against the network before marking it `verified`. Applying a `received` transfer would credit your customer for money that may never settle. The rule: 1. Apply only when `verification.verified` is true. 2. Always read `GET /v1/incoming-transfers/{id}` before applying. A webhook alone is never enough: it is a thin hint and can be a repeat. 3. Applying twice is your bug: key your ledger entry by the transfer `id` (or `trackingKey`). Amounts are integer centavos (`amount: 150050` = $1,500.50), `currency: "MXN"`. `operationDate` follows the [18:00 cut](operation-date-and-cutoff.md). `settledAt` is when the network settled it; `receivedAt` when we got it. ## Webhooks and what they mean | Event | Meaning | Your action | |---|---|---| | `incoming_transfer.verified` | Confirmed | Read the transfer, apply if `verification.verified` | | `incoming_transfer.unidentified` | Confirmed, CLABE without owner | Informational; nothing to apply | | `incoming_transfer.disputed` | Does not match | Do not apply; contact support with the `traceId` if you had expected it | | `incoming_transfer.returned` | Money went back to the payer | Reverse what you applied for that transfer | | `collection_account.activated` | The network accepted the CLABE and its holder | Start offering it to payers | | `collection_account.rejected` | The network refused the CLABE or its holder (final) | Create another CLABE with corrected holder data | | `collection_account.closed` | A CLABE was closed | Stop offering it to payers | | `collection_account.deregistered` | The network removed the CLABE | Nothing: it already returns transfers | | `webhook.test` | Sent by `POST /v1/webhook-endpoints/{id}/test` | Verify your plumbing | There is no webhook for `received`: you are only told when something is actionable. Events are delivered at least once; deduplicate by `webhook-id` ([guide](../how-to/verify-webhooks.md)). ## Returns A transfer to a `closed` CLABE is returned to the payer. A `verified` transfer can later become `returned`; `returnCause` carries the Banxico cause code when the transfer was returned at receipt; it is `null` otherwise. Always handle the event: money you credited must be reversed. ## CLABE lifecycle Created CLABEs start `pending_registration` while the network reviews the holder (up to 48 hours), then become `active` or `rejected` (final). A transfer to a `pending_registration` CLABE is confirmed but not credited to you: it waits in suspense. `rejected`, `closed` and `deregistered` CLABEs return incoming transfers. After a close the network removes the CLABE (`deregistered`). Imported legacy CLABEs (`origin: legacy`) are `active`. `POST /v1/collection-accounts/{id}/close` answers `202`: closing is asynchronous, so wait for `collection_account.closed` or read the CLABE. ## The one rule to apply money Apply a transfer when `verification.verified` is `true` (it is true exactly while `status` is `verified` or `unidentified`; it turns false on `returned`). Check that one field; do not combine it with other signals. --- # Operation date and the 18:00 cut Every incoming transfer has an `operationDate` (`YYYY-MM-DD`), the SPEI business day it belongs to. Use it, not `receivedAt`, for accounting days. ## Rules - The operation date is a **business day in the America/Mexico_City time zone**. - A transfer received **after 18:00:00** belongs to the **next business day**. - Weekends and bank holidays roll to the next business day. - Holidays are loaded from the network operator's calendar (2026 is loaded). The exact cut-off applicability for the holiday zone is to be confirmed. Timestamps in the API (`receivedAt`, `settledAt`) are UTC ISO 8601; convert to Mexico City time before reasoning about the cut. ## Examples | Received (Mexico City) | `operationDate` | |---|---| | Tuesday 17:59:59 | Tuesday | | Tuesday 18:00:00 | Wednesday | | Friday 18:00:00 | Monday (or the next business day if Monday is a holiday) | | Saturday 10:00 | Monday | ## Practical notes - Filter with `GET /v1/incoming-transfers?operationDate=YYYY-MM-DD` for a day's reconciliation. - Do not compute the date yourself; read `operationDate` from the transfer. - The payer's bank receipt (CEP, comprobante electronico de pago) is issued by Banxico's system. Its date can differ from your `operationDate` because of this cut; the CEP is Banxico's. --- # Payment lifecycle ``` pending_approval ──2 approvals──▶ approved ──sent──▶ submitted ──▶ settled ──~2 min──▶ completed │ │ │ │ │ └──────── cancelled ◀────────┘ │ └──▶ returned ◀──────┘ (late return) ├──▶ returned / cancelled └──▶ failed (refused by the network) approved ──▶ failed (signing_rejected: the order changed after approval) ``` **Why `settled` is not `completed`.** SPEI settles an order in seconds, but the receiving bank can still reject it and send it back for about two minutes. Until that window closes the payment is `settled`; afterwards it is `completed`, the only state that means money out. A return can still arrive late; then a `completed` payment becomes `returned` and your balance is restored. **Why nobody can approve alone.** Your system only requests. Two different people of your organisation approve, never the person who requested it, each with a phishing-resistant multi-factor check done in the last five minutes whose challenge is a hash of the amount, the destination and the references. Right before signing, we read the payment again and compare it with what both people approved: if anything changed, it is not sent (`failed`, `signing_rejected`). **Why it never goes twice.** The tracking key is fixed when you request the payment. If the network does not answer in time, we ask it for that key; we only send again with the same key, which the network deduplicates. **Your balance.** An approved payment is reserved from your verified collections when we send it, and given back on `returned`, `cancelled` or `failed`. The daily limit is consumed by the second approval on the operation date of that moment. --- # Reference - **[`../../openapi/openapi.json`](../../openapi/openapi.json)** - the OpenAPI 3.1 contract: every path, field, scope, error and the `webhooks` section. It is generated from the code. When published it is rendered with Scalar or Redoc at the docs site (not live yet, to be confirmed). - **[`../codes.json`](../codes.json)** - error catalogue (code, status, retryable, message). Human version: [errors.md](../how-to/errors.md). - **[`../sdk/`](../sdk/README.md)** - TypeScript types generated from the contract. - **[`../bruno/`](../bruno)** - Bruno collection generated from the contract. - **[`../llms-full.txt`](../llms-full.txt)** - all pages plus a compact API reference appendix. Scopes (prefix `spei-api/`): `accounts.read`, `accounts.write`, `transfers.read`, `balance.read`, `webhooks.manage`, `payments.request` (your system) and `payments.approve` (people only, `typ=human`). --- # Appendix: API reference (generated from openapi/openapi.json) Zetus SPEI API v1. Server: https://api.zenit.zetuspay.com.mx (not live yet, to be confirmed). Every operation requires mutual TLS and a bearer token; the scope is listed per operation. ## GET /v1/balance - operationId: getBalance - scope: balance.read - summary: Get your balance - description: Sum of your verified collections. Unverified (`received`) and `disputed` transfers do not count. - success: 200 - response data fields: - `amount`: integer int64 - Verified collections held for you, in minor units (centavos). - `asOf`: string date-time - When this balance was computed (UTC). It is a projection of the ledger, never a stored number. - `currency`: string enum(MXN) - ISO 4217. - `lastProviderCheck`: - Last check of our books against the network; null before the first one. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/beneficiaries - operationId: listBeneficiaries - scope: accounts.read - summary: List beneficiaries - description: Newest first, by cursor. - parameters: - `status` (query): string enum(pending_verification, verified) - Only this status. - `cursor` (query): string - `meta.nextCursor` of the previous page. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `clabe`: string - 18-digit CLABE. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `ben_`). - `institution`: integer int32 - SPEI institution key (5 digits); ends with the CLABE's first 3. - `name`: string - Holder name as the receiving bank knows it. - `reference`: string - The `reference` that created it. - `status`: string enum(pending_verification, verified) - `pending_verification` until a person of your organisation verifies it in the console. - `taxId`: string|null - RFC or CURP of the holder, if given. - `usableFrom`: string|null date-time - From this instant payments to it can be requested (verification + 24 h). Null until verified. - `verificationChallenge`: string - What the verifier's step-up must sign (its `nonce`). Bound to this exact account and holder. - `verifiedAt`: string|null date-time - UTC, ISO 8601; null until verified. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/beneficiaries - operationId: createBeneficiary - scope: accounts.read - summary: Propose a beneficiary - description: Born `pending_verification`. A different person verifies it in the console; it is usable 24 h after that. Idempotent by `reference`. - request body fields: - `clabe`: string pattern ^[0-9]{18}$ - 18-digit CLABE; the check digit is verified. - `institution`: integer int32 - SPEI institution key (5 digits) of the receiving bank; it ends with the CLABE's first 3 digits. - `name`: string pattern ^[A-Za-z0-9ÁÉÍÓÚÜÑáéíóúüñ&.,-]([A-Za-z0-9ÁÉÍÓÚÜÑáéíóúüñ&., -]{0,38}[A-Za-z0-9ÁÉÍÓÚÜÑáéíóúüñ&.,-])?$ - Holder name as the receiving bank knows it. - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - `taxId`: string|null pattern ^[A-ZÑ0-9&]{2,18}$ - RFC or CURP of the holder (optional; sent as `ND` when absent). - success: 201 - response data fields: - `clabe`: string - 18-digit CLABE. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `ben_`). - `institution`: integer int32 - SPEI institution key (5 digits); ends with the CLABE's first 3. - `name`: string - Holder name as the receiving bank knows it. - `reference`: string - The `reference` that created it. - `status`: string enum(pending_verification, verified) - `pending_verification` until a person of your organisation verifies it in the console. - `taxId`: string|null - RFC or CURP of the holder, if given. - `usableFrom`: string|null date-time - From this instant payments to it can be requested (verification + 24 h). Null until verified. - `verificationChallenge`: string - What the verifier's step-up must sign (its `nonce`). Bound to this exact account and holder. - `verifiedAt`: string|null date-time - UTC, ISO 8601; null until verified. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/beneficiaries/by-reference/{reference} - operationId: getBeneficiaryByReference - scope: accounts.read - summary: Get the beneficiary a reference created - description: `404` means the proposal never arrived: send it again with the same `reference`. - parameters: - `reference` (path, required): string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `clabe`: string - 18-digit CLABE. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `ben_`). - `institution`: integer int32 - SPEI institution key (5 digits); ends with the CLABE's first 3. - `name`: string - Holder name as the receiving bank knows it. - `reference`: string - The `reference` that created it. - `status`: string enum(pending_verification, verified) - `pending_verification` until a person of your organisation verifies it in the console. - `taxId`: string|null - RFC or CURP of the holder, if given. - `usableFrom`: string|null date-time - From this instant payments to it can be requested (verification + 24 h). Null until verified. - `verificationChallenge`: string - What the verifier's step-up must sign (its `nonce`). Bound to this exact account and holder. - `verifiedAt`: string|null date-time - UTC, ISO 8601; null until verified. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/beneficiaries/{id} - operationId: getBeneficiary - scope: accounts.read - summary: Get a beneficiary - description: Another tenant's is 404. - parameters: - `id` (path, required): string - Public id (`ben_…`). - success: 200 - response data fields: - `clabe`: string - 18-digit CLABE. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `ben_`). - `institution`: integer int32 - SPEI institution key (5 digits); ends with the CLABE's first 3. - `name`: string - Holder name as the receiving bank knows it. - `reference`: string - The `reference` that created it. - `status`: string enum(pending_verification, verified) - `pending_verification` until a person of your organisation verifies it in the console. - `taxId`: string|null - RFC or CURP of the holder, if given. - `usableFrom`: string|null date-time - From this instant payments to it can be requested (verification + 24 h). Null until verified. - `verificationChallenge`: string - What the verifier's step-up must sign (its `nonce`). Bound to this exact account and holder. - `verifiedAt`: string|null date-time - UTC, ISO 8601; null until verified. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/beneficiaries/{id}/verification - operationId: verifyBeneficiary - scope: accounts.read - summary: Verify a beneficiary (people only, console) - description: Only a `typ=human` token with `payments.approve`, never the person or system that proposed it, with a step-up whose `nonce` is `verificationChallenge`. A partner token is 403. - parameters: - `id` (path, required): string - Public id (`ben_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - `stepUpToken`: string - The step-up credential the identity provider issued to this person within the last 5 minutes, with phishing-resistant MFA and `nonce` equal to the resource's challenge. - success: 200 - response data fields: - `clabe`: string - 18-digit CLABE. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `ben_`). - `institution`: integer int32 - SPEI institution key (5 digits); ends with the CLABE's first 3. - `name`: string - Holder name as the receiving bank knows it. - `reference`: string - The `reference` that created it. - `status`: string enum(pending_verification, verified) - `pending_verification` until a person of your organisation verifies it in the console. - `taxId`: string|null - RFC or CURP of the holder, if given. - `usableFrom`: string|null date-time - From this instant payments to it can be requested (verification + 24 h). Null until verified. - `verificationChallenge`: string - What the verifier's step-up must sign (its `nonce`). Bound to this exact account and holder. - `verifiedAt`: string|null date-time - UTC, ISO 8601; null until verified. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/collection-accounts - operationId: listCollectionAccounts - scope: accounts.read - summary: List collection CLABEs - description: Newest first, by cursor. - parameters: - `status` (query): string enum(pending_registration, active, rejected, closed, deregistered, registered, registration_failed) - Only this status. `registered` and `registration_failed` are accepted for compatibility and match nothing (deprecated: use `active` and `rejected`). - `cursor` (query): string - `meta.nextCursor` of the previous page. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/collection-accounts - operationId: createCollectionAccount - scope: accounts.write - summary: Create a collection CLABE - description: Generates a new CLABE for your tenant and registers it with the network under its `holder`. It is born `pending_registration`; `collection_account.activated` or `collection_account.rejected` follows (up to 48 h). Idempotent by `reference`: repeating the same request returns the same CLABE; the same `reference` with a different body is `409 idempotency_conflict`. - request body fields: - `holder`: - The person or company the CLABE belongs to. The network registers every CLABE with its holder and reviews it (up to 48 h) before money sent to it is yours. Required. Personal data: it is stored apart and never returned by this API. - `ownerRef`: string|null pattern ^[A-Za-z0-9._:-]{1,128}$ - Your opaque reference for the customer. Never personal data. - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 201 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/collection-accounts/by-clabe/{clabe} - operationId: getCollectionAccountByClabe - scope: accounts.read - summary: Get a collection CLABE by its 18 digits - description: Useful when a payer tells you the CLABE they paid to. - parameters: - `clabe` (path, required): string pattern ^[0-9]{18}$ - 18 digits. - success: 200 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/collection-accounts/by-reference/{reference} - operationId: getCollectionAccountByReference - scope: accounts.read - summary: Get the CLABE a reference created or changed - description: After a timeout, ask here before retrying: `404` means the request never arrived, so send it again with the same `reference`. - parameters: - `reference` (path, required): string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/collection-accounts/{id} - operationId: getCollectionAccount - scope: accounts.read - summary: Get a collection CLABE by id - description: A CLABE of another tenant answers 404, the same as one that does not exist. - parameters: - `id` (path, required): string - Public id (`cla_…`). - success: 200 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 429, 500 ## PATCH /v1/collection-accounts/{id} - operationId: updateCollectionAccount - scope: accounts.write - summary: Change the owner reference of a CLABE - description: Reassigns the CLABE to another of your customers. Idempotent by `reference`; a closed CLABE is 409. - parameters: - `id` (path, required): string - Public id (`cla_…`). - request body fields: - `ownerRef`: string|null pattern ^[A-Za-z0-9._:-]{1,128}$ - New owner reference; null removes it. - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## POST /v1/collection-accounts/{id}/close - operationId: closeCollectionAccount - scope: accounts.write - summary: Close a CLABE - description: Transfers to a closed CLABE are returned to the payer. Answers `202`: the CLABE is closed here at once and deregistered from the network asynchronously. - parameters: - `id` (path, required): string - Public id (`cla_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 202 - response data fields: - `clabe`: string - The 18-digit CLABE payers transfer to. - `closedAt`: string|null date-time - UTC, ISO 8601; null while open. - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `cla_`). - `origin`: string enum(generated, legacy) - `generated` by this API or `legacy` (imported). - `ownerRef`: string|null - Your opaque reference for the customer this CLABE belongs to. Never personal data. - `status`: string enum(pending_registration, active, rejected, closed, deregistered) - Lifecycle: `pending_registration` (the network reviews the holder, up to 48 h) → `active` (accepted) or `rejected` (refused; final); `closed` after you close it, then `deregistered` once the network removed it. Money sent to a CLABE that is not `active` is never credited to you: `pending_registration` waits in suspense, the others are returned to the payer. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/events - operationId: listEvents - scope: any of accounts.read, transfers.read, balance.read, webhooks.manage - summary: Read your event log - description: Oldest first. Catch up after downtime: pass the last `id` you processed as `after`, and repeat with `meta.nextCursor` until it is null. Same bodies as the webhooks. - parameters: - `after` (query): string - Only events after this one (`evt_…`). - `type` (query): string pattern ^[a-z_]+\.[a-z_]+$ - Only this event type. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `data`: object - The resource or its id. Read the resource with the API before acting on money. - `id`: string - Public id (prefix `evt_`), equal to the `webhook-id` header. - `timestamp`: string - When the event happened (UTC). - `type`: string - Event type. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/incoming-transfers - operationId: listIncomingTransfers - scope: transfers.read - summary: List incoming transfers - description: Newest first, by cursor. Filters combine with AND. To match a payment to an invoice, filter by `clabe`, `numericReference` or `trackingKey`. - parameters: - `status` (query): string enum(received, verified, unidentified, disputed, returned) - Only this status. - `operationDate` (query): string date - Only this SPEI operation date (YYYY-MM-DD). - `clabe` (query): string pattern ^[0-9]{18}$ - Only transfers to this CLABE. - `trackingKey` (query): string pattern ^[\x21-\x7E]{1,30}$ - Only this tracking key. - `numericReference` (query): integer int32 - Only this numeric reference typed by the payer. - `cursor` (query): string - `meta.nextCursor` of the previous page. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `amount`: integer int64 - Amount in minor units (centavos): 150050 = $1,500.50. - `clabe`: string - Your CLABE that received the money. - `collectionAccountId`: string|null - The collection account (`cla_…`) of that CLABE, if it is one of yours. - `concept`: string|null - Payment concept typed by the payer. - `currency`: string enum(MXN) - ISO 4217. - `id`: string - Public id (prefix `itr_`). - `numericReference`: integer|null int32 - Numeric reference typed by the payer (0–9999999). - `operationDate`: string date - SPEI operation date (America/Mexico_City business day; after 18:00 it is the next one). - `payer`: object - Who sent the transfer, as the sending bank reported it. - `account`: string|null - Payer account (CLABE, card or phone). - `name`: string|null - Payer name. - `taxId`: string|null - Payer RFC or CURP, when sent. - `receivedAt`: string date-time - When the transfer reached us (UTC). - `returnCause`: integer|null int32 - Banxico return cause when `status` is `returned` at receipt. - `senderInstitution`: integer int32 - Banxico code of the sending institution. - `settledAt`: string|null date-time - When the network settled it, as reported (UTC). - `status`: string enum(received, verified, unidentified, disputed, returned) - `received` (arrived, not confirmed yet: do not apply) → `verified` (apply it) or `disputed` (does not match the network: do not apply). `returned`: sent back to the payer. - `trackingKey`: string - SPEI tracking key (clave de rastreo) of the sending bank. - `verification`: object - Whether the network confirmed this transfer. Apply money only when `verified` is true. - `verified`: boolean - True while the transfer is confirmed against the network's records and not returned. - `verifiedAt`: string|null date-time - When it was confirmed (UTC); kept after a later return. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/incoming-transfers/{id} - operationId: getIncomingTransfer - scope: transfers.read - summary: Get an incoming transfer - description: Read it here before applying a payment: a webhook alone is never enough to apply money. - parameters: - `id` (path, required): string - Public id (`itr_…`). - success: 200 - response data fields: - `amount`: integer int64 - Amount in minor units (centavos): 150050 = $1,500.50. - `clabe`: string - Your CLABE that received the money. - `collectionAccountId`: string|null - The collection account (`cla_…`) of that CLABE, if it is one of yours. - `concept`: string|null - Payment concept typed by the payer. - `currency`: string enum(MXN) - ISO 4217. - `id`: string - Public id (prefix `itr_`). - `numericReference`: integer|null int32 - Numeric reference typed by the payer (0–9999999). - `operationDate`: string date - SPEI operation date (America/Mexico_City business day; after 18:00 it is the next one). - `payer`: object - Who sent the transfer, as the sending bank reported it. - `account`: string|null - Payer account (CLABE, card or phone). - `name`: string|null - Payer name. - `taxId`: string|null - Payer RFC or CURP, when sent. - `receivedAt`: string date-time - When the transfer reached us (UTC). - `returnCause`: integer|null int32 - Banxico return cause when `status` is `returned` at receipt. - `senderInstitution`: integer int32 - Banxico code of the sending institution. - `settledAt`: string|null date-time - When the network settled it, as reported (UTC). - `status`: string enum(received, verified, unidentified, disputed, returned) - `received` (arrived, not confirmed yet: do not apply) → `verified` (apply it) or `disputed` (does not match the network: do not apply). `returned`: sent back to the payer. - `trackingKey`: string - SPEI tracking key (clave de rastreo) of the sending bank. - `verification`: object - Whether the network confirmed this transfer. Apply money only when `verified` is true. - `verified`: boolean - True while the transfer is confirmed against the network's records and not returned. - `verifiedAt`: string|null date-time - When it was confirmed (UTC); kept after a later return. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/limits - operationId: getLimits - scope: accounts.read - summary: Your payment limits - description: Per order, per operation date, and what today's operation date has consumed. 0 = not enabled. - success: 200 - response data fields: - `currency`: string - ISO 4217. - `daily`: integer int64 - Maximum per operation date, minor units. - `operationDate`: string date - The current SPEI operation date (18:00 cut, business days). - `perOrder`: integer int64 - Maximum per payment, minor units. 0 = payments not enabled. - `usedToday`: integer int64 - Consumed by approved payments on `operationDate`, minor units. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/payment-requests - operationId: listPaymentRequests - scope: accounts.read - summary: List payment requests - description: Newest first, by cursor. - parameters: - `status` (query): string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - Only this status. - `cursor` (query): string - `meta.nextCursor` of the previous page. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/payment-requests - operationId: createPaymentRequest - scope: accounts.read - summary: Request a payment - description: Answers `202 pending_approval` with the tracking key assigned. Idempotent by `reference`: repeating it never creates a second payment nor a second tracking key. You never approve it: two people of your organisation do, in the console. - request body fields: - `amount`: integer int64 - Minor units (centavos), at least 1. - `beneficiaryId`: string - A verified beneficiary of yours (`ben_…`), past its 24 h of cooling. - `concept`: string pattern ^[A-Za-z0-9.,:/_-]([A-Za-z0-9 .,:/_-]{0,38}[A-Za-z0-9.,:/_-])?$ - What the beneficiary sees, 1-40 characters, no accents. - `currency`: string enum(MXN) - ISO 4217; only MXN. - `numericReference`: integer int32 - SPEI numeric reference, 0-9999999. - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 202 - response data fields: - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/payment-requests/by-reference/{reference} - operationId: getPaymentRequestByReference - scope: accounts.read - summary: Get the payment a reference created - description: After a timeout, ask here before retrying: `404` means it never arrived; retry with the same `reference`. - parameters: - `reference` (path, required): string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 429, 500 ## GET /v1/payment-requests/{id} - operationId: getPaymentRequest - scope: accounts.read - summary: Get a payment request - description: Another tenant's is 404. - parameters: - `id` (path, required): string - Public id (`pay_…`). - success: 200 - response data fields: - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/payment-requests/{id}/approvals - operationId: approvePaymentRequest - scope: accounts.read - summary: Approve a payment (people only, console) - description: Only a `typ=human` token with `payments.approve`; never the requester; one approval per person; a step-up of 5 minutes or less whose `nonce` is `approvalChallenge`. The second approval applies the per-order and daily limits. A partner token is 403. - parameters: - `id` (path, required): string - Public id (`pay_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - `stepUpToken`: string - The step-up credential the identity provider issued to this person within the last 5 minutes, with phishing-resistant MFA and `nonce` equal to the resource's challenge. - success: 200 - response data fields: - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## POST /v1/payment-requests/{id}/cancel - operationId: cancelPaymentRequest - scope: accounts.read - summary: Cancel a payment request - description: Only before it is sent (`pending_approval` or `approved`); afterwards `409 invalid_state`. - parameters: - `id` (path, required): string - Public id (`pay_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `amount`: integer int64 - Minor units (centavos). - `approvalChallenge`: string - What each approver's step-up must sign (its `nonce`): the hash of amount, destination and references. - `approvals`: integer int32 - Distinct people who approved it so far. - `approvedAt`: string|null date-time - UTC, ISO 8601; null until the second approval. - `beneficiary`: object - The destination, copied when the payment was requested. - `clabe`: string - 18-digit CLABE. - `id`: string - Beneficiary id (`ben_…`). - `institution`: integer int32 - SPEI institution key. - `name`: string - Holder name. - `concept`: string - Payment concept the beneficiary sees. - `createdAt`: string date-time - UTC, ISO 8601. - `currency`: string - ISO 4217. - `failureReason`: string|null - Why it failed, when `failed`: `rejected`, `signing_rejected` or `limit_exceeded`. - `id`: string - Public id (prefix `pay_`). - `limitDate`: string|null date - Operation date whose daily limit it consumed; null until approved. - `numericReference`: integer int32 - SPEI numeric reference, 0–9999999. - `reference`: string - The `reference` that created it. - `requiredApprovals`: integer int32 - Approvals it needs. - `status`: string enum(pending_approval, approved, submitted, settled, completed, returned, cancelled, failed) - `pending_approval` → `approved` → `submitted` → `settled` (provisional) → `completed`; or `returned`, `cancelled`, `failed`. Only `completed` is final money out. - `trackingKey`: string - SPEI tracking key, assigned when you requested it and never changed: quote it to trace the payment. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/webhook-deliveries - operationId: listWebhookDeliveries - scope: webhooks.manage - summary: List webhook deliveries - description: Use `status=failed` to see the dead-letter queue. - parameters: - `status` (query): string enum(pending, in_flight, succeeded, failed) - Only this status. - `cursor` (query): string - `meta.nextCursor` of the previous page. - `limit` (query): integer int32 - Page size, 1–200 (default 50). - success: 200 - response data fields (each item of the list): - `attempts`: integer int32 - Attempts so far. - `deliveredAt`: string|null date-time - UTC; when it was delivered. - `endpointId`: string - The endpoint (`whe_…`). - `eventId`: string - The event (`evt_…`), also its `webhook-id`. - `id`: string - Public id (prefix `whd_`). - `lastAttemptAt`: string|null date-time - UTC. - `lastStatusCode`: integer|null int32 - Last HTTP status your endpoint answered. - `status`: string enum(pending, in_flight, succeeded, failed) - `failed` after 24 h of retries; retry it with POST …/retry. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/webhook-deliveries/{id}/retry - operationId: retryWebhookDelivery - scope: webhooks.manage - summary: Retry a failed delivery - description: Puts a `failed` delivery back in the queue with a fresh 24 h retry window. Only `failed` ones. - parameters: - `id` (path, required): string - Public id (`whd_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 202 - response data fields: - `attempts`: integer int32 - Attempts so far. - `deliveredAt`: string|null date-time - UTC; when it was delivered. - `endpointId`: string - The endpoint (`whe_…`). - `eventId`: string - The event (`evt_…`), also its `webhook-id`. - `id`: string - Public id (prefix `whd_`). - `lastAttemptAt`: string|null date-time - UTC. - `lastStatusCode`: integer|null int32 - Last HTTP status your endpoint answered. - `status`: string enum(pending, in_flight, succeeded, failed) - `failed` after 24 h of retries; retry it with POST …/retry. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## GET /v1/webhook-endpoints - operationId: listWebhookEndpoints - scope: webhooks.manage - summary: List webhook endpoints - description: Secrets are never listed. - success: 200 - response data fields (each item of the list): - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `whe_`). - `secret`: string|null - The signing secret (`whsec_…`). Present only in the answer that created or rotated it (and its idempotent replays); store it now, it is never shown again. - `signatureScheme`: string enum(v1) - Standard Webhooks signature scheme: `v1` = HMAC-SHA256. - `status`: string enum(active, disabled) - `disabled` endpoints receive nothing. - `url`: string - HTTPS URL we POST events to. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/webhook-endpoints - operationId: createWebhookEndpoint - scope: webhooks.manage - summary: Register a webhook endpoint - description: Answers the signing secret (`whsec_…`) once. Verify every delivery with it (Standard Webhooks). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - `url`: string pattern ^https?://\S+$ - HTTPS URL on a public address. - success: 201 - response data fields: - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `whe_`). - `secret`: string|null - The signing secret (`whsec_…`). Present only in the answer that created or rotated it (and its idempotent replays); store it now, it is never shown again. - `signatureScheme`: string enum(v1) - Standard Webhooks signature scheme: `v1` = HMAC-SHA256. - `status`: string enum(active, disabled) - `disabled` endpoints receive nothing. - `url`: string - HTTPS URL we POST events to. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## DELETE /v1/webhook-endpoints/{id} - operationId: disableWebhookEndpoint - scope: webhooks.manage - summary: Disable a webhook endpoint - description: It stops receiving events. - parameters: - `id` (path, required): string - Public id (`whe_…`). - success: 200 - response data fields: - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `whe_`). - `secret`: string|null - The signing secret (`whsec_…`). Present only in the answer that created or rotated it (and its idempotent replays); store it now, it is never shown again. - `signatureScheme`: string enum(v1) - Standard Webhooks signature scheme: `v1` = HMAC-SHA256. - `status`: string enum(active, disabled) - `disabled` endpoints receive nothing. - `url`: string - HTTPS URL we POST events to. - error statuses: 400, 401, 403, 404, 429, 500 ## POST /v1/webhook-endpoints/{id}/rotate-secret - operationId: rotateWebhookSecret - scope: webhooks.manage - summary: Rotate the signing secret - description: Answers the new secret once. For 24 h every delivery is signed with both the previous and the new secret (two `v1,` signatures), so you can switch without losing events. - parameters: - `id` (path, required): string - Public id (`whe_…`). - request body fields: - `reference`: string pattern ^[A-Za-z0-9._-]{1,64}$ - Your idempotency key for this write (the `Idempotency-Key` of this API): 1–64 of `A-Z a-z 0-9 . _ -`, unique per tenant. Retry with the same value and the same body after a timeout. - success: 200 - response data fields: - `createdAt`: string date-time - UTC, ISO 8601. - `id`: string - Public id (prefix `whe_`). - `secret`: string|null - The signing secret (`whsec_…`). Present only in the answer that created or rotated it (and its idempotent replays); store it now, it is never shown again. - `signatureScheme`: string enum(v1) - Standard Webhooks signature scheme: `v1` = HMAC-SHA256. - `status`: string enum(active, disabled) - `disabled` endpoints receive nothing. - `url`: string - HTTPS URL we POST events to. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## POST /v1/webhook-endpoints/{id}/test - operationId: testWebhookEndpoint - scope: webhooks.manage - summary: Send a signed test event - description: Sends a `webhook.test` event now, signed like any other, and answers what your endpoint replied. Not stored in the event log; no `reference` needed (it changes nothing). - parameters: - `id` (path, required): string - Public id (`whe_…`). - success: 200 - response data fields: - `delivered`: boolean - True when your endpoint answered 2xx. - `error`: string|null - Why it could not be delivered, when it could not. - `eventId`: string - The `webhook-id` of the test event, to find it in your logs. - `statusCode`: integer|null int32 - HTTP status your endpoint answered; null if it could not be reached. - error statuses: 400, 401, 403, 404, 409, 429, 500 ## Webhook events - `collection_account.activated`: collection_account.activated The network accepted the CLABE and its holder: transfers to it are now yours. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `collection_account.closed`: collection_account.closed A CLABE was closed; transfers to it are returned. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `collection_account.deregistered`: collection_account.deregistered The network removed the CLABE (after you closed it, or on its own). Transfers to it are returned. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `collection_account.rejected`: collection_account.rejected The network refused the CLABE or its holder. Final: create another CLABE with corrected holder data. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `incoming_transfer.disputed`: incoming_transfer.disputed The transfer does not match the network's records. Do not apply it. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `incoming_transfer.returned`: incoming_transfer.returned The money went back to the payer (returned at receipt or later). Reverse anything you applied. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `incoming_transfer.unidentified`: incoming_transfer.unidentified Confirmed, but the CLABE has no owner. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `incoming_transfer.verified`: incoming_transfer.verified The network confirmed the transfer. This is the one to apply to an invoice, after reading the transfer. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.approved`: payment.approved Two people approved it and it fit the limits; it will be sent. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.cancelled`: payment.cancelled Cancelled before it was settled. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.completed`: payment.completed Final: the return window closed without a return. This is money out. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.failed`: payment.failed It will not be sent or the network refused it; see failureReason. Your balance was restored. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.pending_approval`: payment.pending_approval A payment was requested; it waits for two approvals in the console. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.returned`: payment.returned The receiving bank returned it; your balance was restored. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.settled`: payment.settled Settled, provisionally: the receiving bank may still return it for about two minutes. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `payment.submitted`: payment.submitted The network accepted the order. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes. - `webhook.test`: webhook.test Sent only by POST /v1/webhook-endpoints/{id}/test; data = {endpointId}. Not in the event log. Signed with Standard Webhooks: verify `webhook-signature` over `webhook-id.webhook-timestamp.body` with your `whsec_` secret and reject timestamps older than 5 minutes.