From 160fb463e710c04b7e5f7472cd01f6d2df20f28f Mon Sep 17 00:00:00 2001 From: Adson Rodrigues Date: Mon, 6 Jul 2026 11:08:44 -0300 Subject: [PATCH] docs: reorganiza Guia/Sandbox/Webhooks e alinha payloads ao contrato real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sandbox reduzido a 3 páginas (introdução, cenários, webhooks simulados); remove duplicação de endpoints (auth/cash-in/cash-out) que divergia dos guias - Payload de webhook corrigido para o formato real: flat (event, transactionId, amount, currency, status, clabes, occurredAt), eventos transaction.cash_*.*, status LIQUIDATED/REJECTED/RETURNED/PENDING - Headers reais: x-event-id (dedupe) + X-NTXPay-Signature; remove X-NTXPay-Delivery/Timestamp/Event que não existem - Retry unificado: 5 tentativas, backoff exponencial, timeout 10s - Semântica de refund_in/refund_out corrigida (estava invertida) - Documenta evento curinga 'all' e POST /api/webhooks-config/test (+ stub e nav) - Remove referências internas (Banxico, TigerBeetle, outbox, nomes de MS) - Aplica o modelo nas 3 línguas (pt-br, es, en) Co-Authored-By: Claude Fable 5 --- api-reference/openapi.json | 19 +- docs.json | 20 +- en/endpoints/webhooks-config-test.mdx | 3 + en/guides/authentication.mdx | 168 +++++++++++++-- en/guides/balance.mdx | 16 +- en/guides/get-started.mdx | 38 ++-- en/guides/postman-collections.mdx | 42 ++-- en/guides/spei-cash-in.mdx | 45 ++-- en/guides/spei-cash-out.mdx | 33 ++- en/guides/webhooks/cash-in.mdx | 91 ++++---- en/guides/webhooks/cash-out.mdx | 93 ++++---- en/guides/webhooks/implementation.mdx | 258 ++++++++++++++++------ en/guides/webhooks/overview.mdx | 123 +++++++---- en/guides/webhooks/refund-in.mdx | 64 +++--- en/guides/webhooks/refund-out.mdx | 64 +++--- en/guides/webhooks/setup.mdx | 95 +++++--- en/index.mdx | 2 +- en/sandbox/authentication.mdx | 81 ------- en/sandbox/cash-in.mdx | 91 -------- en/sandbox/cash-out.mdx | 102 --------- en/sandbox/introduction.mdx | 57 +++-- en/sandbox/scenarios.mdx | 115 +++++----- en/sandbox/webhooks.mdx | 112 ++++------ es/endpoints/webhooks-config-test.mdx | 3 + es/guides/authentication.mdx | 168 +++++++++++++-- es/guides/balance.mdx | 30 +-- es/guides/get-started.mdx | 46 ++-- es/guides/postman-collections.mdx | 40 ++-- es/guides/spei-cash-in.mdx | 53 +++-- es/guides/spei-cash-out.mdx | 41 ++-- es/guides/webhooks/cash-in.mdx | 93 ++++---- es/guides/webhooks/cash-out.mdx | 95 ++++---- es/guides/webhooks/implementation.mdx | 262 ++++++++++++++++------ es/guides/webhooks/overview.mdx | 117 ++++++---- es/guides/webhooks/refund-in.mdx | 66 +++--- es/guides/webhooks/refund-out.mdx | 66 +++--- es/guides/webhooks/setup.mdx | 95 +++++--- es/index.mdx | 2 +- es/sandbox/authentication.mdx | 81 ------- es/sandbox/cash-in.mdx | 91 -------- es/sandbox/cash-out.mdx | 102 --------- es/sandbox/introduction.mdx | 59 +++-- es/sandbox/scenarios.mdx | 111 ++++------ es/sandbox/webhooks.mdx | 112 ++++------ pt-br/endpoints/webhooks-config-test.mdx | 3 + pt-br/guides/authentication.mdx | 14 +- pt-br/guides/balance.mdx | 2 +- pt-br/guides/get-started.mdx | 19 +- pt-br/guides/postman-collections.mdx | 12 +- pt-br/guides/spei-cash-in.mdx | 23 +- pt-br/guides/spei-cash-out.mdx | 25 ++- pt-br/guides/webhooks/cash-in.mdx | 216 ++++--------------- pt-br/guides/webhooks/cash-out.mdx | 264 ++++------------------- pt-br/guides/webhooks/implementation.mdx | 132 +++++------- pt-br/guides/webhooks/overview.mdx | 112 ++++++---- pt-br/guides/webhooks/refund-in.mdx | 229 +++----------------- pt-br/guides/webhooks/refund-out.mdx | 229 +++----------------- pt-br/guides/webhooks/setup.mdx | 70 ++++-- pt-br/index.mdx | 2 +- pt-br/sandbox/authentication.mdx | 81 ------- pt-br/sandbox/cash-in.mdx | 91 -------- pt-br/sandbox/cash-out.mdx | 102 --------- pt-br/sandbox/introduction.mdx | 49 ++--- pt-br/sandbox/scenarios.mdx | 105 ++++----- pt-br/sandbox/webhooks.mdx | 110 ++++------ 65 files changed, 2270 insertions(+), 3085 deletions(-) create mode 100644 en/endpoints/webhooks-config-test.mdx delete mode 100644 en/sandbox/authentication.mdx delete mode 100644 en/sandbox/cash-in.mdx delete mode 100644 en/sandbox/cash-out.mdx create mode 100644 es/endpoints/webhooks-config-test.mdx delete mode 100644 es/sandbox/authentication.mdx delete mode 100644 es/sandbox/cash-in.mdx delete mode 100644 es/sandbox/cash-out.mdx create mode 100644 pt-br/endpoints/webhooks-config-test.mdx delete mode 100644 pt-br/sandbox/authentication.mdx delete mode 100644 pt-br/sandbox/cash-in.mdx delete mode 100644 pt-br/sandbox/cash-out.mdx diff --git a/api-reference/openapi.json b/api-reference/openapi.json index 9494ccc..b97557b 100644 --- a/api-reference/openapi.json +++ b/api-reference/openapi.json @@ -355,7 +355,7 @@ "type": "array", "minItems": 1, "maxItems": 1, - "description": "Um webhook assina **exatamente UM** evento. O campo é um array por compatibilidade de contrato, mas deve conter um único item.", + "description": "Um webhook assina **exatamente UM** evento. O campo é um array por compatibilidade de contrato, mas deve conter um único item. Use `all` (Geral) para receber todos os eventos em uma única URL.", "items": { "type": "string", "enum": [ @@ -363,7 +363,8 @@ "cash_out", "refund_in", "refund_out", - "internal_transfer" + "internal_transfer", + "all" ] }, "example": [ @@ -629,7 +630,7 @@ "description": "Token inválido" }, "502": { - "description": "mexico-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } @@ -675,7 +676,7 @@ "description": "Token inválido" }, "502": { - "description": "mexico-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } @@ -708,7 +709,7 @@ "description": "Token inválido ou ausente" }, "502": { - "description": "account-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } @@ -782,7 +783,7 @@ "description": "Token inválido" }, "502": { - "description": "account-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } @@ -828,7 +829,7 @@ "description": "Token inválido" }, "502": { - "description": "notification-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } @@ -875,10 +876,10 @@ "description": "Webhook não encontrado" }, "502": { - "description": "account-ms indisponível" + "description": "Serviço temporariamente indisponível" } } } } } -} \ No newline at end of file +} diff --git a/docs.json b/docs.json index 690356a..2ccc960 100644 --- a/docs.json +++ b/docs.json @@ -54,15 +54,12 @@ "group": "Sandbox", "pages": [ "es/sandbox/introduction", - "es/sandbox/authentication", "es/sandbox/scenarios", - "es/sandbox/cash-in", - "es/sandbox/cash-out", "es/sandbox/webhooks" ] }, { - "group": "Eventos de Webhook", + "group": "Webhooks", "pages": [ "es/guides/webhooks/overview", "es/guides/webhooks/setup", @@ -91,6 +88,7 @@ "pages": [ "es/endpoints/webhooks-config-list", "es/endpoints/webhooks-config-setup", + "es/endpoints/webhooks-config-test", "es/endpoints/webhooks-config-delete" ] } @@ -119,15 +117,12 @@ "group": "Sandbox", "pages": [ "en/sandbox/introduction", - "en/sandbox/authentication", "en/sandbox/scenarios", - "en/sandbox/cash-in", - "en/sandbox/cash-out", "en/sandbox/webhooks" ] }, { - "group": "Webhook Events", + "group": "Webhooks", "pages": [ "en/guides/webhooks/overview", "en/guides/webhooks/setup", @@ -156,6 +151,7 @@ "pages": [ "en/endpoints/webhooks-config-list", "en/endpoints/webhooks-config-setup", + "en/endpoints/webhooks-config-test", "en/endpoints/webhooks-config-delete" ] } @@ -185,15 +181,12 @@ "group": "Sandbox", "pages": [ "pt-br/sandbox/introduction", - "pt-br/sandbox/authentication", "pt-br/sandbox/scenarios", - "pt-br/sandbox/cash-in", - "pt-br/sandbox/cash-out", "pt-br/sandbox/webhooks" ] }, { - "group": "Eventos de Webhook", + "group": "Webhooks", "pages": [ "pt-br/guides/webhooks/overview", "pt-br/guides/webhooks/setup", @@ -222,6 +215,7 @@ "pages": [ "pt-br/endpoints/webhooks-config-list", "pt-br/endpoints/webhooks-config-setup", + "pt-br/endpoints/webhooks-config-test", "pt-br/endpoints/webhooks-config-delete" ] } @@ -269,4 +263,4 @@ "seo": { "indexHiddenPages": false } -} \ No newline at end of file +} diff --git a/en/endpoints/webhooks-config-test.mdx b/en/endpoints/webhooks-config-test.mdx new file mode 100644 index 0000000..930b541 --- /dev/null +++ b/en/endpoints/webhooks-config-test.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /api/webhooks-config/test +--- diff --git a/en/guides/authentication.mdx b/en/guides/authentication.mdx index ec9fa06..ece8712 100644 --- a/en/guides/authentication.mdx +++ b/en/guides/authentication.mdx @@ -1,16 +1,21 @@ --- title: 'Authentication' -description: 'X.509 certificate (mTLS) + OAuth 2.0 client_credentials to get an access JWT' +description: 'Certificate + OAuth 2.0 client_credentials to obtain an access JWT' +mode: 'wide' --- ## Overview -The NTX Pay México API uses two-layer authentication: +The NTX Pay Mexico API uses two-layer authentication: -1. **X.509 certificate (mTLS)** — delivered by NTX Pay at onboarding, proves the client server's identity. -2. **OAuth 2.0 client_credentials** — `clientId` + `clientSecret` received during onboarding, validated together with the certificate. +1. **Certificate** — delivered by NTX Pay during onboarding, proves the identity of the client server. +2. **OAuth 2.0 client_credentials** — `clientId` + `clientSecret` provided during onboarding, validated together with the certificate. -The combination returns a **JWT** (10-minute validity) used in the other endpoints as `Authorization: Bearer ...`. +The combination returns a **JWT** (valid for 10 minutes) used on the remaining endpoints as `Authorization: Bearer ...`. + + + Authentication in the **sandbox is identical** — what changes is the certificate + `clientId`/`clientSecret` pair, which is distinct from production. Production credentials against `https://sandbox.mx.ntxpay.com` return `401`. + ## Endpoint @@ -23,13 +28,13 @@ X-SSL-Client-Cert: Content-Type: application/json ``` -The `X-SSL-Client-Cert` is typically injected by NGINX/ALB with the URL-encoded certificate: +The `X-SSL-Client-Cert` header is typically injected by NGINX/ALB with the URL-encoded certificate: ```nginx proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; ``` -In development, URL-encode manually: +In development, URL-encode it manually: ```bash ENCODED_CERT=$(cat client.cert.pem | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read()))") @@ -60,7 +65,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ ## Using the Token -Include the `access_token` in all authenticated requests: +Include the `access_token` in every authenticated request: ```bash curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ @@ -69,24 +74,26 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ ## Renewal -The token expires in **10 minutes (600s)**. Repeat step 1 before expiration — there is no refresh token. +The token expires in **10 minutes (600s)**. Repeat step 1 before it expires — there is no refresh token. - Don't cache the token across processes without an invalidation mechanism. Under high load, generate one token per worker and renew every ~8 minutes to avoid `401` due to expiration. + Do not cache the token across processes without an invalidation mechanism. Under high load, generate one token per worker and renew every ~8 minutes to avoid `401` errors due to expiration. ## Common Errors | Code | Cause | Solution | |---|---|---| -| `400` | Missing `X-SSL-Client-Cert` | Configure NGINX/ALB to forward the certificate | +| `400` | `X-SSL-Client-Cert` missing | Configure NGINX/ALB to forward the certificate | | `400` | Malformed PEM | Verify that the certificate starts with `-----BEGIN CERTIFICATE-----` | -| `401` | Invalid `clientId`/`clientSecret` | Re-check credentials (no trailing spaces) | -| `401` | Expired/revoked certificate | Request renewal from NTX Pay | +| `401` | Invalid `clientId`/`clientSecret` | Double-check the credentials (no extra spaces) | +| `401` | Expired/revoked certificate | Request a renewal from NTX Pay | + +## Code Examples -## Node.js Example + -```typescript +```typescript Node.js import fs from 'fs'; import axios from 'axios'; @@ -111,13 +118,136 @@ async function getToken(): Promise { } ``` +```python Python +import os +import urllib.parse +import requests + +with open("client.cert.pem", "r") as f: + cert = f.read() +encoded_cert = urllib.parse.quote(cert) + +def get_token() -> str: + resp = requests.post( + "https://sandbox.mx.ntxpay.com/api/auth/token", + json={ + "clientId": os.environ["NTXPAY_CLIENT_ID"], + "clientSecret": os.environ["NTXPAY_CLIENT_SECRET"], + }, + headers={ + "X-SSL-Client-Cert": encoded_cert, + "Content-Type": "application/json", + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json()["access_token"] +``` + +```java Java +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class NtxPayAuth { + public static String getToken() throws Exception { + String cert = Files.readString(Path.of("client.cert.pem")); + String encodedCert = URLEncoder.encode(cert, StandardCharsets.UTF_8); + + String body = """ + { + "clientId": "%s", + "clientSecret": "%s" + } + """.formatted( + System.getenv("NTXPAY_CLIENT_ID"), + System.getenv("NTXPAY_CLIENT_SECRET") + ); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("https://sandbox.mx.ntxpay.com/api/auth/token")) + .header("X-SSL-Client-Cert", encodedCert) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse resp = HttpClient.newHttpClient() + .send(req, HttpResponse.BodyHandlers.ofString()); + + // Parse access_token with the JSON library of your choice (Jackson, Gson, etc.) + return resp.body(); + } +} +``` + +```go Go +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "os" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` +} + +func getToken() (string, error) { + certBytes, err := os.ReadFile("client.cert.pem") + if err != nil { + return "", err + } + encodedCert := url.QueryEscape(string(certBytes)) + + payload, _ := json.Marshal(map[string]string{ + "clientId": os.Getenv("NTXPAY_CLIENT_ID"), + "clientSecret": os.Getenv("NTXPAY_CLIENT_SECRET"), + }) + + req, err := http.NewRequest( + "POST", + "https://sandbox.mx.ntxpay.com/api/auth/token", + bytes.NewReader(payload), + ) + if err != nil { + return "", err + } + req.Header.Set("X-SSL-Client-Cert", encodedCert) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return "", err + } + return tr.AccessToken, nil +} +``` + + + ## Next Steps - - Full flow (token → transaction) - - Use the token to query the balance + Apply the Bearer token and query the account balance + + + Create your first SPEI charge diff --git a/en/guides/balance.mdx b/en/guides/balance.mdx index fb0a03c..1b26516 100644 --- a/en/guides/balance.mdx +++ b/en/guides/balance.mdx @@ -1,14 +1,14 @@ --- title: 'Balance Query' -description: 'Account available and pending balance in MXN centavos' +description: 'Available and pending account balance in MXN centavos' --- ## Overview The `GET /api/balance` endpoint returns the authenticated account's balance in **MXN centavos** (integers). There are two fields: -- **`availableCentavos`** — balance available to send SPEI cash-out -- **`pendingCentavos`** — blocked balance (cash-out in processing, cash-in confirming) +- **`availableCentavos`** — balance available for sending SPEI cash-out +- **`pendingCentavos`** — blocked balance (cash-out in processing, cash-in awaiting confirmation) ## Endpoint @@ -46,11 +46,11 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ - Pending balance in MXN centavos. Includes cash-out in processing and cash-in waiting for final settlement confirmation. + Pending balance in MXN centavos. Includes cash-out in processing and cash-in awaiting final settlement confirmation. - Always `MXN` in the México scope. + Always `MXN` within the Mexico scope. ## Node.js Example @@ -92,16 +92,16 @@ async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) ``` - Even validating the balance beforehand, the cash-out may fail with `400` if another concurrent cash-out consumes the balance. Treat the `400` error as "insufficient balance" at the call time. + Even after validating the balance, the cash-out can still fail with `400` if a concurrent cash-out consumes the balance. Treat the `400` error as "insufficient balance" at call time. ## Response Codes | Code | Meaning | |---|---| -| `200` | Balance queried | +| `200` | Balance retrieved | | `401` | Invalid or missing token | -| `502` | `account-ms` unavailable | +| `502` | Service temporarily unavailable — try again | ## Next Steps diff --git a/en/guides/get-started.mdx b/en/guides/get-started.mdx index d8b4c17..7f7d3f3 100644 --- a/en/guides/get-started.mdx +++ b/en/guides/get-started.mdx @@ -1,37 +1,35 @@ --- title: 'Get Started' -description: 'Welcome to the NTX Pay México API. This guide walks you through the first steps to integrate and start using SPEI.' +description: 'Welcome to the NTX Pay Mexico API. This guide walks you through the first steps to integrate and start using SPEI.' +mode: 'wide' --- ## Overview -The NTX Pay México API lets your company perform payment operations, check balances, reconcile transactions and receive webhook notifications — all securely and at scale. Authentication is via **X.509 certificate (mTLS) + OAuth 2.0**, returning a short-lived **JWT** for each request. +The NTX Pay Mexico API lets your company execute payment operations, reconcile transactions, and receive webhook notifications — all in a secure and scalable way. Authentication uses an **X.509 certificate (mTLS) + OAuth 2.0**, returning a short-lived **JWT** for each request. -## Getting Started with Sandbox +## Step by Step in the Sandbox Contact your account manager or write to `contact@ntxpay.com` to request a sandbox account. - You will receive a **certificate** and **OAuth credentials** (`clientId` + `clientSecret`) for the sandbox account. + You will receive a **certificate** and the **OAuth credentials** (`clientId` + `clientSecret`) associated with the sandbox account. - Register your webhook URL via `POST /api/webhooks-config` so you can receive simulated webhook events. + Register your webhook URL via `POST /api/webhooks-config` to receive simulated events. ```bash curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://my-server.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"] }' ``` - - In the dashboard, go to **Settings** to confirm your account is configured in sandbox mode. - Authenticate, make API calls, and use the `X-Sandbox-Scenario` header to simulate different outcomes (immediate confirmation, failure, expiration, etc.). @@ -50,13 +48,29 @@ The NTX Pay México API lets your company perform payment operations, check bala -## Environments +## Environment | Environment | URL | |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | -| Production | Provided at onboarding | + +## Integration Path + + + + Certificate + OAuth 2.0 to obtain the JWT + + + Receive payments via a disposable CLABE + + + Receive notifications for every event + + + Test scenarios and simulated webhooks + + ## Support -`contact@ntxpay.com` · `https://app.ntxpay.com` +`suporte@ntxpay.com` diff --git a/en/guides/postman-collections.mdx b/en/guides/postman-collections.mdx index 4cee607..17ff4f3 100644 --- a/en/guides/postman-collections.mdx +++ b/en/guides/postman-collections.mdx @@ -1,27 +1,19 @@ --- title: 'Postman Collections' description: 'Import the official Postman collection to test the NTX Pay API locally.' +mode: 'wide' --- -## Download +## How to Get the Collection -We maintain a ready-to-use Postman collection with all public endpoints of the NTX Pay México API. - - - - Postman Collection v2.1 - - - Sandbox and Production - - +The official Postman collection with all public NTX Pay Mexico endpoints is distributed on demand. Request the `.json` file (Postman v2.1) by email at `suporte@ntxpay.com`. ## Import into Postman 1. Open Postman 2. **File → Import** -3. Select the downloaded `.json` file -4. Also import the environments zip and pick **Sandbox** to start testing +3. Select the `.json` file you received +4. Also import the provided environment and select **Sandbox** to start testing ## Configure Environment Variables @@ -29,31 +21,33 @@ The environment expects 3 variables: | Variable | Description | Example | |---|---|---| -| `clientId` | OAuth client_id of your account | `qr-93-550e8400` | +| `clientId` | Your account's OAuth client_id | `qr-93-550e8400` | | `clientSecret` | OAuth client_secret | `a1b2c3d4...` | -| `certificatePem` | X.509 certificate in a single line (no line breaks, with literal `\n`) | `-----BEGIN CERTIFICATE-----\n...` | +| `certificatePem` | X.509 certificate on a single line (no line breaks, with literal `\n`) | `-----BEGIN CERTIFICATE-----\n...` | ## Suggested Flow -The collection is organized in typical usage order: +The collection is organized in the typical order of use: -1. **Auth → Generate Token** — run this first. The response auto-saves `access_token` to an environment variable. -2. **Balance → Get Balance** — confirm the token works. -3. **SPEI → Cash-In** — create a simulated charge. -4. **Transactions → List** — check status. +1. **Auth → Generate Token** — run this first. The response automatically saves the `access_token` to an environment variable. +2. **SPEI → Cash-In** — creates a simulated charge to confirm the token is working. +3. **Webhooks Config → Test** — fires a test webhook at your endpoint. ## Available Folders - `Auth` — JWT generation +- `SPEI` — cash-in, cash-out - `Balance` — balance query -- `SPEI` — cash-in, cash-out, transaction by external ID -- `Transactions` — list -- `Webhooks Config` — list, create, delete +- `Webhooks Config` — list, create, test, delete ## Alternatives -Prefer a different tool? +Prefer another tool? - **Insomnia**: import the same `.json` (Postman v2.1 compatible) - **OpenAPI / Bruno**: use our [`openapi.json`](/api-reference/openapi.json) directly - **cURL**: every endpoint page in this documentation has a ready-to-copy cURL example + +## Support + +`suporte@ntxpay.com` diff --git a/en/guides/spei-cash-in.mdx b/en/guides/spei-cash-in.mdx index 13fbc69..b22e526 100644 --- a/en/guides/spei-cash-in.mdx +++ b/en/guides/spei-cash-in.mdx @@ -1,17 +1,17 @@ --- title: 'SPEI Cash-In' -description: 'Receive SPEI payments via one-time disposable CLABE' +description: 'Receive SPEI payments via a single-use disposable CLABE' --- ## Overview -**SPEI cash-in** generates a **disposable CLABE** that the payer uses to make a SPEI transfer from their banking app. When NTX Pay receives the settlement, the transaction moves to `CONFIRMED` and triggers the `cash_in` webhook. +**SPEI cash-in** generates a **disposable CLABE** that the payer uses to make a SPEI transfer from their banking app. When NTX Pay receives the settlement, you are notified on the `cash_in` webhook with the `transaction.cash_in.settled` event. Characteristics: - CLABE valid for a **single** transfer (one-time use) - **Asynchronous** confirmation (seconds to minutes) -- Expires on configurable date (default ~24 hours) +- Expires at a configurable date (default ~24 hours) ## Endpoint @@ -58,14 +58,16 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ } ``` +Display the `destinationClabe` (and/or the `checkoutUrl`) to the end payer. + ## Request Fields - Value in MXN centavos (minimum 1). Ex.: `50000` = $500.00 MXN. + Amount in MXN centavos (minimum 1). E.g. `50000` = $500.00 MXN. - Unique external identifier (up to 100 characters). Use to correlate with your system. Recommended for idempotency. + Unique external identifier (up to 100 characters). Use it to correlate with your system. Recommended for idempotency. @@ -73,7 +75,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ - Payer name (1–255 characters), shown on the SPEI checkout. + Payer name (1–255 characters), displayed on the SPEI checkout. @@ -90,35 +92,50 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ sequenceDiagram participant App as Your application participant NTX as NTX Pay - participant Payer + participant Payer as Payer participant Bank as Payer's bank App->>NTX: POST /api/spei/cash-in NTX-->>App: 201 + destinationClabe - App->>Payer: Show CLABE (and/or checkoutUrl) + App->>Payer: Display CLABE (and/or checkoutUrl) Payer->>Bank: SPEI transfer to destinationClabe Bank-->>NTX: SPEI settlement - NTX->>App: webhook cash_in (CONFIRMED) + NTX->>App: cash_in webhook (transaction.cash_in.settled) ``` ## Transaction States | Status | Meaning | |---|---| -| `PENDING` | CLABE issued, waiting for transfer | +| `PENDING` | CLABE issued, waiting for the transfer | | `CONFIRMED` | Transfer received and settled | | `FAILED` | Processing error | -| `EXPIRED` | CLABE expired without receiving transfer | +| `EXPIRED` | CLABE expired without receiving a transfer | + +On the webhook, the settlement arrives as `transaction.cash_in.settled` with `status: LIQUIDATED` — see the [full payload](/en/guides/webhooks/cash-in). ## Idempotency -Resend the same request with the same `externalId` to ensure that a network failure doesn't generate two charges. In case of duplication, NTX Pay returns the existing charge. +Resend the same request with the same `externalId` to guarantee a network failure does not create two charges. On duplication, NTX Pay returns the existing charge. + +## Testing in the Sandbox + +In the sandbox, settlement is simulated within seconds — no issuing bank required. Control the outcome with the `X-Sandbox-Scenario` header: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Sandbox-Scenario: rejected" \ + ... +``` + +See the [scenario catalog](/en/sandbox/scenarios) to force rejection, return, and delay. ## Next Steps - - Details of the confirmation webhook payload + + Confirmation webhook payload Send SPEI transfers diff --git a/en/guides/spei-cash-out.mdx b/en/guides/spei-cash-out.mdx index 4daa451..e4811ec 100644 --- a/en/guides/spei-cash-out.mdx +++ b/en/guides/spei-cash-out.mdx @@ -5,7 +5,7 @@ description: 'Send SPEI transfers to any CLABE' ## Overview -**SPEI cash-out** sends an interbank transfer to a **destination CLABE**. The account balance is debited and NTX Pay processes the transfer over the SPEI network. Confirmation arrives via `cash_out` webhook. +**SPEI cash-out** sends an interbank transfer to a **destination CLABE**. The account balance is debited and NTX Pay processes the transfer on the SPEI network. Confirmation arrives on the `cash_out` webhook with the `transaction.cash_out.settled` event. ## Endpoint @@ -49,7 +49,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ ## Request Fields - Value in MXN centavos (minimum 1). Ex.: `50000` = $500.00 MXN. + Amount in MXN centavos (minimum 1). E.g. `50000` = $500.00 MXN. @@ -80,16 +80,20 @@ if (balance.availableCentavos < amountCentavos) { ``` - Insufficient balance returns `400` — the transaction is **not created**. Apply idempotency on the client side (don't reprocess the same order after `400` without revalidating the balance). + Insufficient balance returns `400` — the transaction is **not created**. Apply idempotency on the client side (do not reprocess the same order after a `400` without revalidating the balance). ## States | Status | Meaning | |---|---| -| `PENDING` | Cash-out accepted, waiting for SPEI settlement | -| `CONFIRMED` | Settled at Banxico | -| `FAILED` | Rejected by the SPEI network | +| `PENDING` | Cash-out accepted, awaiting SPEI settlement | +| `CONFIRMED` | Settled on the SPEI network | +| `FAILED` | Rejected by the SPEI network — the debited balance is refunded | + +On the webhook, the outcomes arrive as `transaction.cash_out.settled` (`LIQUIDATED`) and `transaction.cash_out.rejected` (`REJECTED`) — see the [full payload](/en/guides/webhooks/cash-out). + +**Return after settlement:** if the counterparty's bank returns the transfer, the balance is credited back and you receive `transaction.cash_out.returned` on the [`refund_out`](/en/guides/webhooks/refund-out) webhook. ## Error Codes @@ -97,7 +101,7 @@ if (balance.availableCentavos < amountCentavos) { |---|---| | `400` | Insufficient balance, invalid CLABE, invalid payload | | `401` | Invalid token | -| `502` | Service temporarily unavailable — don't retry without checking status via `GET /api/transactions` | +| `502` | Temporary processing failure — do not retry without confirming the outcome of the original transaction (wait for the webhook) | ## Node.js Example with Retry @@ -112,18 +116,25 @@ async function speiCashOut(token: string, dto: any) { return data; // status: PENDING } catch (err) { if (err.response?.status === 502) { - // We don't know if the transaction was created. Query /api/transactions filtering by externalId - // before retrying. + // We don't know whether the transaction was created. Wait for the webhook + // (or contact support) before retrying — a blind retry can duplicate the transfer. } throw err; } } ``` +## Testing in the Sandbox + +In the sandbox, the full pipeline runs — balance debited, fee charged, statement generated — and settlement is simulated within seconds. Your account needs balance: do a [cash-in](/en/guides/spei-cash-in) first. Force rejection, return, and synchronous failures with the `X-Sandbox-Scenario` header — see the [scenario catalog](/en/sandbox/scenarios). + ## Next Steps - - Details of the settlement webhook payload + + Settlement webhook payload + + + How cash-out returns arrive diff --git a/en/guides/webhooks/cash-in.mdx b/en/guides/webhooks/cash-in.mdx index 1b30012..217b176 100644 --- a/en/guides/webhooks/cash-in.mdx +++ b/en/guides/webhooks/cash-in.mdx @@ -1,48 +1,55 @@ --- -title: 'cash_in event' -description: 'Notification sent when a SPEI cash-in is confirmed' +title: 'cash_in Webhook' +description: 'Lifecycle notifications for a SPEI cash-in' +mode: 'wide' --- ## When it fires -The `cash_in` event fires when: +The `cash_in` webhook type receives the lifecycle events of a charge created via `POST /api/spei/cash-in`: -- A SPEI transfer arrives at the **disposable CLABE** issued by `POST /api/spei/cash-in` and is settled +| `event` | `status` | Meaning | +|---|---|---| +| `transaction.cash_in.settled` | `LIQUIDATED` | The SPEI transfer arrived at the disposable CLABE and was settled | +| `transaction.cash_in.rejected` | `REJECTED` | The SPEI network rejected the transfer | +| `transaction.cash_in.pending` | `PENDING` | Intermediate processing update | + + + The **return** of an already settled cash-in (`transaction.cash_in.returned`) is delivered on the [`refund_in`](/en/guides/webhooks/refund-in) webhook type, not on `cash_in`. + ## Payload ```json { - "event": "cash_in", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-05-12T14:31:05.000Z", - "transaction": { - "id": 12345, - "externalId": "order-abc-123", - "paymentMethod": "SPEI", - "direction": "in", - "type": "cash_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-12T14:30:00.000Z", - "confirmedAt": "2026-05-12T14:31:05.000Z" - } + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" } ``` +- `destinationClabe` — the disposable CLABE issued when the charge was created +- `sourceClabe` — the payer's CLABE (when provided by the network) +- `amount` — amount in MXN cents +- Reference fields (`reference`, `voucher`) may be `null` depending on the flow + ## Headers | Header | Value | |---|---| -| `X-NTXPay-Event` | `cash_in` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch (seconds) | -| `X-NTXPay-Delivery` | unique delivery UUID | +| `x-event-id` | Unique event UUID (use for dedupe) | +| `X-NTXPay-Signature` | `sha256=` of the raw body | ## Expected Response -Respond `200 OK` in under 10 seconds. On any other status, NTX Pay retries up to 5 times in exponential backoff. +Respond `200 OK` in under 10 seconds. On any status other than `2xx`, NTX Pay retries up to 5 times with exponential backoff. ```http HTTP/1.1 200 OK @@ -51,35 +58,15 @@ Content-Type: application/json {"received": true} ``` -## Handler Example (Node.js / Express) - -```typescript -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body for HMAC +## Processing -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; +Mark the order as paid only when `event` is `transaction.cash_in.settled` (or `status: LIQUIDATED`): -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) // req.body is Buffer - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'cash_in' && event.transaction.status === 'CONFIRMED') { - enqueue(event); // process async - } - - res.json({ received: true }); -}); +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.settled') { + await markOrderPaid(event.transactionId, event.amount); +} ``` -See the [implementation guide](/en/guides/webhooks/implementation) for Python and PHP. +For the complete handler with HMAC validation and dedupe in Node.js, Python, Java, and Go, see [Implementation](/en/guides/webhooks/implementation). diff --git a/en/guides/webhooks/cash-out.mdx b/en/guides/webhooks/cash-out.mdx index ee31f44..213a68a 100644 --- a/en/guides/webhooks/cash-out.mdx +++ b/en/guides/webhooks/cash-out.mdx @@ -1,79 +1,68 @@ --- -title: 'cash_out event' -description: 'Notification sent when a SPEI cash-out is settled or fails' +title: 'cash_out Webhook' +description: 'Lifecycle notifications for a SPEI cash-out' +mode: 'wide' --- ## When it fires -The `cash_out` event fires in two scenarios: +The `cash_out` webhook type receives the lifecycle events of a transfer created via `POST /api/spei/cash-out`: -- **Success** — the SPEI cash-out sent via `POST /api/spei/cash-out` was settled at Banxico (`status: CONFIRMED`) -- **Failure** — the SPEI network rejected the transfer (`status: FAILED`) +| `event` | `status` | Meaning | +|---|---|---| +| `transaction.cash_out.settled` | `LIQUIDATED` | The transfer was settled on the SPEI network | +| `transaction.cash_out.rejected` | `REJECTED` | The SPEI network rejected the transfer — the debited balance is returned | +| `transaction.cash_out.pending` | `PENDING` | Intermediate processing update | -## Payload (confirmed) + + The **return** of an already settled cash-out (`transaction.cash_out.returned`) — when the counterparty's bank returns the transfer — is delivered on the [`refund_out`](/en/guides/webhooks/refund-out) webhook type, not on `cash_out`. + -```json -{ - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-05-13T12:00:42.000Z", - "transaction": { - "id": 56789, - "externalId": "payout-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": "2026-05-13T12:00:42.000Z" - } -} -``` - -## Payload (failure) +## Payload ```json { - "event": "cash_out", - "deliveryId": "...", - "createdAt": "2026-05-13T12:01:00.000Z", - "transaction": { - "id": 56789, - "status": "FAILED", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": null - } + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260513D4E5F6", + "occurredAt": "2026-05-13T12:00:42.000Z" } ``` -When `status: FAILED`, the blocked balance is automatically released. +On `status: REJECTED`, the receipt fields (`reference`, `voucher`) may be `null` — the SPEI network never confirmed the transaction. ## Headers | Header | Value | |---|---| -| `X-NTXPay-Event` | `cash_out` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch | -| `X-NTXPay-Delivery` | UUID | +| `x-event-id` | Unique event UUID (use for dedupe) | +| `X-NTXPay-Signature` | `sha256=` of the raw body | ## Behavior -- **At-least-once**: you may receive `CONFIRMED` more than once. Deduplicate by `transaction.id`. -- **Failure after success**: doesn't happen. A transaction won't move from `CONFIRMED` to `FAILED`. -- **Reversal**: if the counterparty (beneficiary) returns, you receive a separate `refund_in` event, with `transaction.type = "refund_in"` linked by the `externalId`. +- **At-least-once**: you may receive the same event more than once. Deduplicate by `x-event-id`. +- **Failure after success**: does not happen. A transaction never moves from `LIQUIDATED` to `REJECTED`. +- **Return**: if the counterparty returns the transfer after settlement, you receive `transaction.cash_out.returned` on the `refund_out` webhook, with the same `transactionId`. ## Expected Response -```http -HTTP/1.1 200 OK +`200 OK` within 10 seconds. + +## Processing + +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.settled') { + await markPayoutSettled(event.transactionId); +} else if (event.event === 'transaction.cash_out.rejected') { + await markPayoutFailed(event.transactionId); // balance returned automatically +} ``` -See the [implementation guide](/en/guides/webhooks/implementation) for HMAC validation. +For the complete handler with HMAC validation and dedupe in Node.js, Python, Java, and Go, see [Implementation](/en/guides/webhooks/implementation). diff --git a/en/guides/webhooks/implementation.mdx b/en/guides/webhooks/implementation.mdx index 1dc3834..0eab09c 100644 --- a/en/guides/webhooks/implementation.mdx +++ b/en/guides/webhooks/implementation.mdx @@ -1,25 +1,28 @@ --- title: 'Webhook Implementation' -description: 'HMAC validation and idempotent processing in Node, Python and PHP' +description: 'HMAC validation and idempotent processing in Node.js, Python, Java, and Go' +mode: 'wide' --- ## Principles -Any webhook implementation needs to cover 3 things: +Every webhook implementation needs to cover 3 things: -1. **HMAC validation** with the `secret` received when creating the webhook +1. **HMAC validation** with the `secret` received when the webhook was created 2. **Fast response** (`200 OK` in ≤10s) -3. **Idempotency** via `X-NTXPay-Delivery` or `transaction.id` +3. **Idempotency** via the `x-event-id` header -## Node.js / Express +## Code Examples -```typescript + + +```typescript Node.js import express from 'express'; import crypto from 'crypto'; const app = express(); -// CRITICAL: use raw body, not parsed JSON, so the HMAC matches +// CRITICAL: use raw body, not parsed JSON, so HMAC matches app.use('/webhooks/ntxpay', express.raw({ type: 'application/json' })); const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; @@ -27,7 +30,7 @@ const seen = new Set(); // production: Redis with TTL app.post('/webhooks/ntxpay', async (req, res) => { const sig = req.header('X-NTXPay-Signature') ?? ''; - const deliveryId = req.header('X-NTXPay-Delivery') ?? ''; + const eventId = req.header('x-event-id') ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', SECRET) @@ -40,21 +43,19 @@ app.post('/webhooks/ntxpay', async (req, res) => { } // Dedupe - if (seen.has(deliveryId)) return res.json({ duplicate: true }); - seen.add(deliveryId); + if (seen.has(eventId)) return res.json({ duplicate: true }); + seen.add(eventId); const event = JSON.parse(req.body.toString()); - // Process async — don't block the response + // Process async — don't block response enqueue(event).catch(console.error); res.json({ received: true }); }); ``` -## Python / Flask - -```python +```python Python import hmac import hashlib from flask import Flask, request, abort, jsonify @@ -65,17 +66,17 @@ seen = set() # production: Redis with TTL @app.post('/webhooks/ntxpay') def webhook(): - raw = request.get_data() # raw bytes + raw = request.get_data() # raw bytes — essential so HMAC matches sig = request.headers.get('X-NTXPay-Signature', '') - delivery_id = request.headers.get('X-NTXPay-Delivery', '') + event_id = request.headers.get('x-event-id', '') expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401) - if delivery_id in seen: + if event_id in seen: return jsonify(duplicate=True) - seen.add(delivery_id) + seen.add(event_id) event = request.get_json() # enqueue asynchronously @@ -84,84 +85,213 @@ def webhook(): return jsonify(received=True) ``` -## PHP +```java Java +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@RestController +public class NtxPayWebhook { + private static final byte[] SECRET = + System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); + // production: Redis with TTL + private final Set seen = ConcurrentHashMap.newKeySet(); + + @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity handle( + @RequestHeader("X-NTXPay-Signature") String sig, + @RequestHeader("x-event-id") String eventId, + @RequestBody byte[] raw // raw bytes — essential so HMAC matches + ) throws Exception { + String expected = "sha256=" + hmacSha256Hex(SECRET, raw); + if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { + return ResponseEntity.status(401).build(); + } + if (!seen.add(eventId)) { + return ResponseEntity.ok(Map.of("duplicate", true)); + } + + // enqueue async processing + return ResponseEntity.ok(Map.of("received", true)); + } -```php - true]); - exit; -} -$_SESSION['seen'][$deliveryId] = true; + if !hmac.Equal([]byte(sig), []byte(expected)) { + w.WriteHeader(http.StatusUnauthorized) + return + } -$event = json_decode($raw, true); + seenMu.Lock() + if seen[eventID] { + seenMu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]bool{"duplicate": true}) + return + } + seen[eventID] = true + seenMu.Unlock() -// Queue async processing -// processAsync($event); + // enqueue async processing + _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) +} -http_response_code(200); -echo json_encode(['received' => true]); +func main() { + http.HandleFunc("/webhooks/ntxpay", handleWebhook) + _ = http.ListenAndServe(":8080", nil) +} ``` + + ## Why raw body? -HMAC is computed over the **exact bytes** NTX Pay sent. If the framework parses JSON before (reordering whitespace, fields), the signature won't match. Always capture the raw body in **bytes** before parsing. +The HMAC is computed over the **exact bytes** that NTX Pay sent. If your framework parses the JSON first (rearranging whitespace, reordering fields), the signature won't match. Always capture the raw body as **bytes** before parsing. -## Events by Status +## Routing by Event and Status -Filter before processing: +The `event` field identifies the flow (`transaction.cash_in.*` / `transaction.cash_out.*`) and `status` the outcome. Filter before processing: -```typescript + + +```typescript Node.js const event = JSON.parse(req.body.toString()); switch (event.event) { - case 'cash_in': - if (event.transaction.status === 'CONFIRMED') { - await markOrderPaid(event.transaction.externalId); - } + case 'transaction.cash_in.settled': + await markOrderPaid(event.transactionId, event.amount); break; - case 'cash_out': - if (event.transaction.status === 'CONFIRMED') { - await markPayoutSettled(event.transaction.id); - } else if (event.transaction.status === 'FAILED') { - await markPayoutFailed(event.transaction.id); - } + case 'transaction.cash_out.settled': + await markPayoutSettled(event.transactionId); + break; + + case 'transaction.cash_out.rejected': + await markPayoutFailed(event.transactionId); break; - case 'refund_in': - case 'refund_out': + case 'transaction.cash_in.returned': + case 'transaction.cash_out.returned': await processRefund(event); break; } ``` +```python Python +event = request.get_json() + +match event["event"]: + case "transaction.cash_in.settled": + mark_order_paid(event["transactionId"], event["amount"]) + case "transaction.cash_out.settled": + mark_payout_settled(event["transactionId"]) + case "transaction.cash_out.rejected": + mark_payout_failed(event["transactionId"]) + case "transaction.cash_in.returned" | "transaction.cash_out.returned": + process_refund(event) +``` + +```java Java +// `raw` is the request body byte[], `mapper` is a Jackson ObjectMapper +Map event = mapper.readValue(raw, new TypeReference<>() {}); +String evtType = (String) event.get("event"); +String txId = (String) event.get("transactionId"); + +switch (evtType) { + case "transaction.cash_in.settled" -> + markOrderPaid(txId, ((Number) event.get("amount")).longValue()); + case "transaction.cash_out.settled" -> markPayoutSettled(txId); + case "transaction.cash_out.rejected" -> markPayoutFailed(txId); + case "transaction.cash_in.returned", "transaction.cash_out.returned" -> + processRefund(event); +} +``` + +```go Go +var event struct { + Event string `json:"event"` + TransactionID string `json:"transactionId"` + Amount int64 `json:"amount"` + Status string `json:"status"` +} +if err := json.Unmarshal(raw, &event); err != nil { + return err +} + +switch event.Event { +case "transaction.cash_in.settled": + markOrderPaid(event.TransactionID, event.Amount) +case "transaction.cash_out.settled": + markPayoutSettled(event.TransactionID) +case "transaction.cash_out.rejected": + markPayoutFailed(event.TransactionID) +case "transaction.cash_in.returned", "transaction.cash_out.returned": + processRefund(event) +} +``` + + + ## Retries -If you return a non-`2xx` status, NTX Pay retries up to **5 times** in exponential backoff (~30s, 1m, 5m, 15m, 1h). After that, the event is dropped. To resend manually, use the panel or contact support. +If you return a status ≠ `2xx` (or exceed the 10s timeout), NTX Pay retries up to **5 times** with exponential backoff starting at ~5 seconds. After that, the delivery is marked as failed — a manual redelivery can be requested from support. - Don't use `429` to signal your own service's rate-limit — it triggers retry and amplifies load. Respond `503 Service Unavailable` if you really can't process. + Do not use `429` to signal rate limiting on your own service — it triggers retries and amplifies the load. Respond `503 Service Unavailable` if you genuinely cannot process. ## Best Practices -- **Use Redis/DB for dedupe** with TTL ≥ 24h (not in-process memory) -- **Process async**: webhook handler just validates + enqueues -- **Monitor latency** of the handler — target P95 < 500ms -- **Log `X-NTXPay-Delivery`** for auditing -- **Re-query `/api/transactions`** if the webhook brings state conflicting with your DB +- **Use Redis/a database for dedupe** with a TTL ≥ 24h (not in-process memory) +- **Process asynchronously**: the webhook handler should only validate + enqueue +- **Monitor handler latency** — target P95 < 500ms +- **Log `x-event-id`** for auditing diff --git a/en/guides/webhooks/overview.mdx b/en/guides/webhooks/overview.mdx index 7867ed6..10ecd98 100644 --- a/en/guides/webhooks/overview.mdx +++ b/en/guides/webhooks/overview.mdx @@ -1,67 +1,108 @@ --- title: 'Webhooks Overview' description: 'Automatic notifications for SPEI events' +mode: 'wide' --- -## What are Webhooks +## What Are Webhooks -Webhooks let NTX Pay send HTTPS notifications to your server whenever a relevant event happens — cash-in confirmation, cash-out failure, etc. — without you having to poll `GET /api/transactions`. +Webhooks let NTX Pay send HTTPS notifications to your server whenever a relevant event occurs — cash-in confirmation, cash-out failure, return — without you having to poll. -## Available Events +## Webhook Types -| Event | When it fires | -|---|---| -| `cash_in` | SPEI cash-in confirmed | -| `cash_out` | SPEI cash-out settled | -| `refund_in` | **Received** refund (one of your cash-outs was returned) | -| `refund_out` | **Sent** refund (you refunded a cash-in) | -| `internal_transfer` | Internal transfer between NTX Pay accounts | - -## Configuration - -Endpoint: `POST /api/webhooks-config`. You provide: - -- **`url`** — HTTPS endpoint on your server -- **`events`** — array with exactly 1 event (one webhook subscribes to one event) -- **`secret`** — optional. If omitted, NTX Pay generates one automatically and returns it in the response. +When registering a webhook via `POST /api/webhooks-config`, you choose **which event type** that URL receives: -See the [Setup guide](/en/guides/webhooks/setup) for the step-by-step. - -## Security — HMAC Signature - -Every webhook is signed with HMAC-SHA256 using the `secret`. Headers sent: +| Type | Receives | +|---|---| +| `cash_in` | Outcome of SPEI charges (confirmed, rejected, pending) | +| `cash_out` | Outcome of SPEI transfers (settled, rejected, pending) | +| `refund_in` | Return of a **cash-in** — a payment you received was reversed back to the payer | +| `refund_out` | Return of a **cash-out** — a transfer you sent was returned by the counterparty | +| `all` | **General** — a single URL that receives all the events above | + + + Each webhook subscribes to **exactly one** type. To receive multiple types on separate URLs, create one webhook per type — or use `all` to centralize everything on one URL and route by the payload's `event` field. + + +## Payload + +Every webhook delivers the same payload format: + +```json +{ + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" +} +``` + + + Event type: `transaction.cash_in.settled`, `transaction.cash_in.rejected`, `transaction.cash_in.returned`, `transaction.cash_out.settled`, `transaction.cash_out.rejected`, `transaction.cash_out.returned`, or the `.pending` variants. Use this field to route processing. + + + + Transaction identifier. Correlate it with the `id` returned when the charge/transfer was created. + + + + Amount in MXN centavos. + + + + `LIQUIDATED` (settled), `REJECTED` (rejected), `RETURNED` (returned), or `PENDING` (processing). + + + + Destination and source CLABEs of the transfer. May be `null` depending on the flow. + + + + SPEI numeric reference and receipt, when available. + + + + ISO 8601 timestamp of the event. + + +## Headers | Header | Content | |---|---| -| `X-NTXPay-Signature` | `sha256=` of the raw body | -| `X-NTXPay-Timestamp` | Unix timestamp of the delivery | -| `X-NTXPay-Event` | event name (`cash_in`, etc.) | -| `X-NTXPay-Delivery` | unique delivery UUID (use for dedupe) | +| `x-event-id` | Unique event UUID — use it to **deduplicate** | +| `X-NTXPay-Signature` | `sha256=` of the raw body, signed with the webhook's `secret` | +| `Content-Type` | `application/json` | -**Always validate the signature** before processing — without it, anyone can forge notifications. +**Always validate the signature** before processing — without it, anyone can forge notifications. See [Implementation](/en/guides/webhooks/implementation). ## Delivery Guarantees -- **At-least-once**: you may receive the same event more than once. Use `X-NTXPay-Delivery` to deduplicate. -- **Retries**: up to 5 attempts in exponential backoff if you respond with a non-`2xx` status. -- **Timeout**: 10 seconds. Respond fast — process asynchronously if needed. -- **Order**: events may arrive **out of order** under error conditions. Check `createdAt` in the payload. +- **At-least-once**: you may receive the same event more than once. Deduplicate by `x-event-id`. +- **Retries**: up to **5 attempts** with exponential backoff (starting at ~5s) if you respond with a non-`2xx` status. +- **Timeout**: **10 seconds**. Respond fast — process asynchronously if needed. +- **Ordering**: events may arrive out of order under error conditions. Check `occurredAt` in the payload. -## Recommended Practices +## Best Practices -1. **Respond `200` immediately** after validating the signature and queueing the event. -2. **Deduplicate by `X-NTXPay-Delivery`** or `transaction.id`. -3. **Idempotency**: process confirmed `cash_in` for the same `externalId` only once. -4. **Re-query** `/api/transactions` if in doubt about state — the webhook is an optimization, not the source of truth. -5. **HTTPS required** — webhooks are only delivered to HTTPS URLs. +1. **Respond `200` immediately** after validating the signature and enqueueing the event. +2. **Deduplicate by `x-event-id`**. +3. **Route by the `event` field** — do not assume a URL receives a single type (especially with `all`). +4. **Handle `status` explicitly** — implement all four states (`LIQUIDATED`, `REJECTED`, `RETURNED`, `PENDING`). +5. **HTTPS required** — webhooks are only sent to URLs with the HTTPS protocol. ## Next Steps - - Configure the endpoint on your account + + Register the URL on your account and trigger a test webhook - HMAC validation examples in Node, Python and PHP + HMAC validation examples in Node.js, Python, Java, and Go diff --git a/en/guides/webhooks/refund-in.mdx b/en/guides/webhooks/refund-in.mdx index da8a417..d0980fe 100644 --- a/en/guides/webhooks/refund-in.mdx +++ b/en/guides/webhooks/refund-in.mdx @@ -1,58 +1,56 @@ --- -title: 'refund_in event' -description: 'Received refund notification — one of your cash-out transfers was returned' +title: 'refund_in Webhook' +description: 'Return of a cash-in — a received payment was reversed back to the payer' +mode: 'wide' --- ## When it fires -The `refund_in` event fires when a **cash-out transaction you sent** is returned by the counterparty. The corresponding balance is credited back to your account. +The `refund_in` webhook type receives the `transaction.cash_in.returned` event: a **cash-in you received was returned to the payer**. The corresponding balance is debited from your account. Common scenarios: -- Beneficiary manually rejected the transfer -- CLABE existed but the account was closed after initial confirmation -- Refund requested by the beneficiary within the SPEI deadline +- Reversal due to fraud or error +- Return within the SPEI network's window ## Payload ```json { - "event": "refund_in", - "deliveryId": "5b9c2d8e-4f12-4a18-bb29-88a3b4c5d6f7", - "createdAt": "2026-05-14T09:15:00.000Z", - "transaction": { - "id": 67890, - "externalId": "payout-001-refund", - "paymentMethod": "SPEI", - "direction": "in", - "type": "refund_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T09:14:50.000Z", - "confirmedAt": "2026-05-14T09:15:00.000Z" - }, - "originalTransactionId": 56789 + "event": "transaction.cash_in.returned", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": null, + "occurredAt": "2026-05-14T11:45:00.000Z" } ``` -The `originalTransactionId` field points to the `id` of the original cash-out that was refunded. Use it to correlate. +The `transactionId` is the **same** as the original cash-in — use it to locate the order and mark it as reversed. + +## Headers + +| Header | Value | +|---|---| +| `x-event-id` | Unique event UUID (use for dedupe) | +| `X-NTXPay-Signature` | `sha256=` of the raw body | ## Expected Response -`HTTP 200 OK` within 10 seconds. +`200 OK` within 10 seconds. -## Recommended Processing +## Processing ```typescript -if (event.event === 'refund_in') { - // Balance credit already happened automatically - await markPayoutAsRefunded({ - originalId: event.originalTransactionId, - refundId: event.transaction.id, - amount: event.transaction.amountCentavos, - }); +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.returned') { + // The balance debit has already happened automatically + await markOrderRefunded(event.transactionId, event.amount); } ``` -See the [implementation guide](/en/guides/webhooks/implementation) for HMAC validation. +For the complete handler with HMAC validation and dedupe in Node.js, Python, Java, and Go, see [Implementation](/en/guides/webhooks/implementation). diff --git a/en/guides/webhooks/refund-out.mdx b/en/guides/webhooks/refund-out.mdx index 9339625..75c2f6b 100644 --- a/en/guides/webhooks/refund-out.mdx +++ b/en/guides/webhooks/refund-out.mdx @@ -1,57 +1,57 @@ --- -title: 'refund_out event' -description: 'Sent refund notification — you returned a received cash-in transaction' +title: 'refund_out Webhook' +description: 'Return of a cash-out — a sent transfer was returned by the counterparty' +mode: 'wide' --- ## When it fires -The `refund_out` event fires when a **cash-in you received is returned to the payer**. The corresponding balance is debited from your account. +The `refund_out` webhook type receives the `transaction.cash_out.returned` event: a **transfer you sent was returned** by the counterparty's bank. The corresponding balance is credited back to your account. Common scenarios: -- You triggered a refund for fraud or error -- Customer requested cancellation within the SPEI deadline +- Invalid destination CLABE or closed account +- The beneficiary/counterparty bank rejected the transfer after initial acceptance +- Return within the SPEI network's window ## Payload ```json { - "event": "refund_out", - "deliveryId": "7d2c9e8f-5b34-4c19-aa18-99b3c4d5e6f7", - "createdAt": "2026-05-14T11:45:00.000Z", - "transaction": { - "id": 78901, - "externalId": "order-abc-123-refund", - "paymentMethod": "SPEI", - "direction": "out", - "type": "refund_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T11:44:50.000Z", - "confirmedAt": "2026-05-14T11:45:00.000Z" - }, - "originalTransactionId": 12345 + "event": "transaction.cash_out.returned", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": null, + "occurredAt": "2026-05-14T09:15:00.000Z" } ``` -`originalTransactionId` points to the `id` of the original cash-in that was returned. +The `transactionId` is the **same** as the original cash-out — use it to correlate and mark the payment as returned. + +## Headers + +| Header | Value | +|---|---| +| `x-event-id` | Unique event UUID (use for dedupe) | +| `X-NTXPay-Signature` | `sha256=` of the raw body | ## Expected Response -`HTTP 200 OK`. +`200 OK` within 10 seconds. -## Recommended Processing +## Processing ```typescript -if (event.event === 'refund_out') { - // Balance already debited - await markOrderAsRefunded({ - originalCashInId: event.originalTransactionId, - refundId: event.transaction.id, - amount: event.transaction.amountCentavos, - }); +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.returned') { + // The balance credit back has already happened automatically + await markPayoutReturned(event.transactionId, event.amount); } ``` -See the [implementation guide](/en/guides/webhooks/implementation) for HMAC validation. +For the complete handler with HMAC validation and dedupe in Node.js, Python, Java, and Go, see [Implementation](/en/guides/webhooks/implementation). diff --git a/en/guides/webhooks/setup.mdx b/en/guides/webhooks/setup.mdx index 4a84c42..1b4fc8e 100644 --- a/en/guides/webhooks/setup.mdx +++ b/en/guides/webhooks/setup.mdx @@ -1,15 +1,17 @@ --- title: 'Webhook Setup' -description: 'Configure webhook URLs programmatically for SPEI' +description: 'Register, test, list, and delete webhook URLs programmatically' +mode: 'wide' --- ## Overview -Webhook configuration goes through three endpoints: +Webhook configuration is done via four endpoints: - `GET /api/webhooks-config` — list active webhooks - `POST /api/webhooks-config` — create/configure a webhook -- `DELETE /api/webhooks-config/{id}` — remove a webhook +- `POST /api/webhooks-config/test` — trigger a signed test webhook +- `DELETE /api/webhooks-config/{id}` — delete a webhook ## Create Webhook @@ -20,7 +22,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://my-server.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "secret": "whsec_abc123def456" }' @@ -31,7 +33,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ ```json { "id": 42, - "url": "https://my-server.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "isActive": true, "secret": "whsec_abc123def456" @@ -39,23 +41,67 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ ``` - If you omit `secret` in the request, NTX Pay generates one automatically and returns it in the response — **store it immediately**, it won't be shown again. + If you omit `secret` in the request, NTX Pay generates one automatically and returns it in the response — **store it immediately**, it is never shown again. ### Fields - HTTPS endpoint URL to receive webhooks. **Plain HTTP is rejected.** + HTTPS URL of the endpoint that will receive the webhooks. **Plain HTTP is rejected.** - A webhook subscribes to **exactly ONE** event — the array must contain a single item. Accepted values: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `internal_transfer`. To receive more than one event type, create one webhook per event. + A webhook subscribes to **exactly ONE** event — the array must contain a single item. Accepted values: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `all` (General — receives all events), and `internal_transfer`. See the semantics of each type in the [Overview](/en/guides/webhooks/overview). - HMAC secret to validate signature. Minimum 8 characters, maximum 128. If omitted, NTX Pay generates one. + HMAC secret for signature validation. Minimum 8 characters, maximum 128. If omitted, NTX Pay generates one. +## Test Webhook + +After creating the webhook, trigger a test delivery **signed with the same secret** — no need to move a transaction: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "eventType": "cash_in", + "status": "LIQUIDATED" + }' +``` + +```json +{ + "delivered": true, + "url": "https://meu-servidor.com/webhooks/ntxpay", + "eventId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "status": "LIQUIDATED", + "signed": true, + "statusCode": 200, + "timeMs": 184 +} +``` + + + Which webhook receives the test: `cash_in`, `cash_out`, `refund_in`, `refund_out`, or `internal_transfer`. + + + + Simulated status in the payload: `LIQUIDATED` (default), `PENDING`, `REJECTED`, or `RETURNED`. + + + + Temporary test URL (e.g. webhook.site). If omitted, delivers to the configured URL. + + + + Amount in centavos in the test payload (default `1000` = $10.00 MXN). + + +`delivered: true` means your endpoint responded `2xx`. `statusCode: 0` indicates a connection error. + ## List Webhooks ```bash @@ -69,7 +115,7 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/webhooks-config \ "webhooks": [ { "id": 42, - "url": "https://my-server.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "isActive": true, "createdAt": "2026-05-01T10:30:00.000Z" @@ -80,10 +126,10 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/webhooks-config \ ``` - The list response does **not** include the `secret` — it's only shown on creation. + The list response does **not** include the `secret` — it is only shown at creation time. -## Remove Webhook +## Delete Webhook ```bash curl -X DELETE https://sandbox.mx.ntxpay.com/api/webhooks-config/42 \ @@ -93,36 +139,35 @@ curl -X DELETE https://sandbox.mx.ntxpay.com/api/webhooks-config/42 \ ```json { "success": true, - "message": "Webhook removed successfully" + "message": "Webhook removido com sucesso" } ``` ## Multiple Webhooks -Each webhook subscribes to exactly one event, so configure **one webhook per event type** you want to receive (e.g. one for `cash_in`, another for `cash_out`). Useful for: +Each webhook subscribes to exactly one event, so you have two strategies: -- Routing each event type to its own endpoint/handler -- Internal **homologation** vs production environment -- Multiple services consuming different events +- **One webhook per type** (e.g. one for `cash_in`, another for `cash_out`) — routes each type to its own endpoint/handler. +- **One `all` webhook** — a single URL receives everything and your handler routes by the payload's `event` field. -## Testing the Endpoint +## Validating the Endpoint -Before configuring in production, validate your endpoint: +Before releasing the webhook to receive real traffic: -1. Configure in sandbox first -2. Use [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) to inspect the traffic -3. Verify your application: +1. Use [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) to inspect the traffic (the test webhook's `overrideUrl` field accepts these URLs) +2. Trigger deliveries with `POST /api/webhooks-config/test`, varying the `status` +3. Verify that your application: - Validates `X-NTXPay-Signature` correctly - Returns `200` in under 10 seconds - - Deduplicates by `X-NTXPay-Delivery` + - Deduplicates by `x-event-id` ## Next Steps - HMAC validation in Node, Python and PHP + HMAC validation in Node.js, Python, Java, and Go - Payload of each event type + Payload for each event type diff --git a/en/index.mdx b/en/index.mdx index b982d08..1df6079 100644 --- a/en/index.mdx +++ b/en/index.mdx @@ -3,7 +3,7 @@ title: 'NTX Pay México API' description: 'SPEI integration in a single API' --- -Public gateway for SPEI (instant interbank transfers) integration. Receive and send via SPEI, query balance and transactions, receive signed webhooks. +Public gateway for SPEI (instant interbank transfers) integration. Receive and send via SPEI, query balance, receive signed webhooks. ## Environments diff --git a/en/sandbox/authentication.mdx b/en/sandbox/authentication.mdx deleted file mode 100644 index d866ee9..0000000 --- a/en/sandbox/authentication.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: 'Authentication' -description: 'Authentication in sandbox is structurally identical to production.' -mode: 'wide' ---- - -## Overview - -Sandbox authentication uses the same two layers as production: - -1. **X.509 certificate (mTLS)** — issued by NTX Pay at onboarding. -2. **OAuth 2.0 `client_credentials`** — `clientId` + `clientSecret` received during onboarding. - -Together they return a **JWT** (10-minute validity) used on the remaining endpoints as `Authorization: Bearer ...`. - - - Sandbox credentials are **distinct** from production. If you use production credentials against `https://sandbox.mx.ntxpay.com`, you will get `401`. The HTTP contract is identical — what changes is the certificate + clientId/clientSecret pair. - - -## Get a Token - -### POST /api/auth/token - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ - -H "X-SSL-Client-Cert: $ENCODED_CERT" \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "qr-93-550e8400", - "clientSecret": "a1b2c3d4e5f6g7h8" - }' -``` - -#### Response (201) - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "token_type": "Bearer", - "expires_in": 600, - "scope": "email profile" -} -``` - -## Use the Token - -On a sandbox account, any authenticated call simulates the full pipeline without moving real money: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "externalId": "test-001" - }' -``` - -The response is always `201 Created` with `status: PENDING`. The final outcome (confirmation or failure) arrives via webhook ~1 second later. See [Scenarios](/en/sandbox/scenarios) to force specific outcomes. - -## Renewal - -The token expires in **10 minutes (600s)**. There is no refresh token — get a new one via `POST /api/auth/token` before expiration. - - - Under high load, mint a token per worker and renew every ~8 minutes to avoid `401` due to expiration. - - -## Common Errors - -| Code | Cause | Fix | -|---|---|---| -| `400` | `X-SSL-Client-Cert` missing | Configure NGINX/ALB to forward the certificate | -| `401` | Invalid `clientId`/`clientSecret` | Double-check credentials; confirm you are using the sandbox ones | -| `401` | Certificate expired/revoked | Request renewal from NTX Pay | - -## Detailed documentation - -For the full step-by-step (certificate encoding, examples in multiple languages, etc.) see [Authentication](/en/guides/authentication) in the main guide — the only difference is the base URL `https://sandbox.mx.ntxpay.com`. diff --git a/en/sandbox/cash-in.mdx b/en/sandbox/cash-in.mdx deleted file mode 100644 index f5dca3a..0000000 --- a/en/sandbox/cash-in.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: 'Cash-in (SPEI receive)' -description: 'How to generate a disposable cash-in CLABE in sandbox.' -mode: 'wide' ---- - -## What it does - -`POST /api/spei/cash-in` generates a **disposable CLABE** bound to your sandbox account. Any SPEI transfer received at that CLABE triggers a `cash_in` webhook to the configured URL. - -In sandbox, confirmation is **simulated** ~1 second after CLABE creation (instead of waiting for a real transfer). This lets you test the entire cash-in flow without depending on a real issuing bank. - -## Example - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 50000, - "externalId": "order-001", - "customerName": "Juan Pérez", - "customerEmail": "juan@example.com" - }' -``` - -### Response (201) - -```json -{ - "id": 12345, - "externalId": "order-001", - "status": "PENDING", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "expiresAt": "2026-03-26T10:30:00.000Z" -} -``` - -Use the returned `clabe` to display to the end payer (your company's customer). In sandbox this CLABE is fictitious, but the `transaction.clabe` field arriving in the webhook will be **the same**. - -## Expected webhook - -After ~1 second (default `success` scenario): - -```json -{ - "event": "cash_in", - "deliveryId": "...", - "transaction": { - "id": 12345, - "externalId": "order-001", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "confirmedAt": "2026-03-26T10:00:01.000Z", - "counterpart": { - "name": "Simulated Payer", - "taxId": "PAGS850101ABC", - "bank": { - "code": "012", - "name": "BBVA México" - } - } - } -} -``` - -## Test scenarios - -| Scenario | Webhook | -|---|---| -| `success` (default) | `CONFIRMED` | -| `pending_long` | `CONFIRMED` after ~30s | -| `rejected` | `FAILED` | -| `returned` | `RETURNED` | - -`timeout` and `provider_5xx` fail on the synchronous HTTP response. `insufficient_funds` and `bad_clabe` do **not** apply to cash-in (they return `400 SCENARIO_NOT_APPLICABLE`). - -See [Scenarios](/en/sandbox/scenarios) for the full list. - -## Next steps - - - - How to send SPEI in sandbox. - - - Understanding webhook delivery in sandbox. - - diff --git a/en/sandbox/cash-out.mdx b/en/sandbox/cash-out.mdx deleted file mode 100644 index 2ffcb14..0000000 --- a/en/sandbox/cash-out.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: 'Cash-out (SPEI send)' -description: 'How to send SPEI to a destination CLABE in sandbox.' -mode: 'wide' ---- - -## What it does - -`POST /api/spei/cash-out` requests a SPEI transfer to a destination CLABE. In sandbox, the full accounting pipeline runs — balance is debited, fee is charged, statement entry is generated — but the Banxico call is simulated. - -The HTTP response is always `201 Created` with `status: PENDING`. The final outcome arrives via `cash_out` webhook ~1 second later (`success` scenario) or as forced by the scenario. - -## Prerequisite - -Your sandbox account needs balance. Run at least one [cash-in](/en/sandbox/cash-in) first — simulated balance is debited just like in production. - -## Example - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "beneficiaryTaxId": "LOPM850101ABC", - "concept": "Invoice payment" - }' -``` - -### Response (201) - -```json -{ - "id": 12346, - "status": "PENDING", - "destinationClabe": "012180001234567890", - "amountCentavos": 15000, - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T10:00:00.000Z" -} -``` - -## Expected webhook - -After ~1 second (`success` scenario): - -```json -{ - "event": "cash_out", - "deliveryId": "...", - "transaction": { - "id": 12346, - "externalId": "payout-001", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "confirmedAt": "2026-03-26T10:00:01.000Z" - }, - "errorCode": null, - "errorMessage": null -} -``` - -## Useful error scenarios - -Force specific behaviors via the `X-Sandbox-Scenario` header: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: insufficient_funds" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -| Scenario | When to use | Webhook | -|---|---|---| -| `insufficient_funds` | Validate UX when your customer tries to pay without balance | `FAILED` (`errorCode: INSUFFICIENT_FUNDS`) | -| `bad_clabe` | Validate handling of a transfer returned for an invalid CLABE | `RETURNED` (`errorCode: INVALID_CLABE`) | -| `rejected` | Validate generic SPEI network rejection | `FAILED` | -| `returned` | Validate a transfer reversed by the counterpart bank | `RETURNED` | -| `pending_long` | Validate "transfer in progress" UX (~30s) | `CONFIRMED` | -| `timeout` / `provider_5xx` | Validate synchronous upstream failures (`504` / `503`) | — (synchronous error) | - -See [Scenarios](/en/sandbox/scenarios) for the full list. - -## Synchronous validations - -Even in sandbox, some validations happen **before** the `201` is returned: - -| Synchronous error | HTTP | When | -|---|---|---| -| `400 INVALID_AMOUNT` | `400` | `amountCentavos <= 0` | -| `400 INVALID_CLABE_FORMAT` | `400` | CLABE with invalid format (not exactly 18 digits) | -| `400 INSUFFICIENT_FUNDS` | `400` | Real balance below `amountCentavos + fee` (without using a scenario) | - - - The `insufficient_funds`, `bad_clabe`, `rejected` and `returned` scenarios affect the **webhook** — the synchronous response is `201 PENDING`. `timeout` and `provider_5xx` fail synchronously, as do structural validations (amount/CLABE format). - diff --git a/en/sandbox/introduction.mdx b/en/sandbox/introduction.mdx index 2071be1..5f6899d 100644 --- a/en/sandbox/introduction.mdx +++ b/en/sandbox/introduction.mdx @@ -1,20 +1,24 @@ --- title: 'NTX Pay Sandbox' -description: 'Test environment with high fidelity to the NTX Pay México production pipeline.' +description: 'Test environment with high fidelity to production behavior.' mode: 'wide' --- ## What it is -The NTX Pay sandbox lets your integration exercise **cash-in**, **cash-out**, **refund**, and **webhooks** without moving real money. Unlike simplistic mocks, the full accounting pipeline (TigerBeetle balance, limit validation, fee charging, statement generation, webhook delivery via outbox) is exercised intact. Only settlement on the SPEI network is simulated. +The NTX Pay sandbox lets your integration exercise **cash-in**, **cash-out**, **returns**, and **webhooks** without moving real money. The full pipeline runs — balance, limit validation, fee charging, statement, and webhook delivery — only settlement on the SPEI network is simulated. - Every NTX Pay integration starts in sandbox. The endpoints, payloads, and webhooks described in this documentation are the final ones — when production is enabled for your company, the same code works by just swapping credentials. + **Your integration does not change.** The endpoints, payloads, and webhooks are exactly those described in the [guides](/en/guides/get-started) — once production is enabled for your company, the same code will work by simply swapping credentials. That is why this section documents only **what is different in the sandbox**: the [test scenarios](/en/sandbox/scenarios) and the [simulated webhooks](/en/sandbox/webhooks). -## How to enable +## How to enable it -Your API credentials are **structurally the same** as those you would use in production. The difference lives at the account level: sandbox accounts route SPEI calls to NTX Pay's internal simulator. To create a sandbox account, contact your Account Manager or write to `contact@ntxpay.com` — onboarding is instant and KYC is auto-approved. +Your API credentials are **structurally the same** as those you would use in production. The difference lives in the account: sandbox accounts route SPEI calls to NTX Pay's internal simulator. To create a sandbox account, ask your Account Manager or write to `contact@ntxpay.com` — onboarding is instant and KYC is auto-approved. + + + Sandbox credentials are **distinct** from production credentials. Production credentials against the sandbox host return `401`. [Authentication](/en/guides/authentication) itself is identical. + ## Base URL @@ -22,46 +26,37 @@ Your API credentials are **structurally the same** as those you would use in pro |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | -All documented routes (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/transactions`, `/api/webhooks-config`) are available exactly at this host. - -## Test scenarios - -You control the behavior of each call via the `X-Sandbox-Scenario` HTTP header. Without the header, the sandbox returns **success** by default. See [Scenarios](/en/sandbox/scenarios) for the full list of supported error, success, and delay scenarios. - -## Webhooks - -Register your `webhookUrl` on the sandbox account exactly as you would in production — via `POST /api/webhooks-config`. Events are delivered by the same outbox engine we use in prod, with the same signatures, headers (`X-NTXPay-Delivery`), and retry policy. +All documented routes (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/balance`, `/api/webhooks-config`) are available at exactly this host. ## Differences vs Production | Aspect | Sandbox | Production | |---|---|---| -| Base URL | `https://sandbox.mx.ntxpay.com` | Provided at onboarding | +| Base URL | `https://sandbox.mx.ntxpay.com` | Provided during onboarding | | Balance | Simulated | Real funds | -| SPEI cash-in confirmation | Immediate (~1s) | Real (seconds to minutes) | -| `X-Sandbox-Scenario` | Supported | Rejected with `400` | +| SPEI settlement | Simulated, in seconds | Real (seconds to minutes) | +| `X-Sandbox-Scenario` header | Supported | Rejected with `400` | | Cost | Free | Per contract | +## Suggested test flow + +1. **Authenticate** — [obtain the JWT](/en/guides/authentication) with your sandbox credentials. +2. **Register your webhook** — via [`POST /api/webhooks-config`](/en/guides/webhooks/setup), exactly as in production. +3. **Create a cash-in** — follow the [cash-in guide](/en/guides/spei-cash-in); the simulated confirmation arrives in seconds. +4. **Send a cash-out** — using the balance from the previous step, follow the [cash-out guide](/en/guides/spei-cash-out). +5. **Force errors and returns** — use the [scenarios](/en/sandbox/scenarios) to exercise every path in your handler. + ## Next steps - - How to obtain the JWT in sandbox using your credentials. - - - Full list of scenarios available via `X-Sandbox-Scenario`. - - - Receive via SPEI in sandbox. - - - Send via SPEI in sandbox. + + Force success, failure, return, and delay via the `X-Sandbox-Scenario` header. - - How the sandbox delivers webhooks and how to test dedupe. + + How to trigger each event and validate dedupe, retries, and signature. ## Support -`support@ntxpay.com` +`suporte@ntxpay.com` diff --git a/en/sandbox/scenarios.mdx b/en/sandbox/scenarios.mdx index a2cad06..4e2f0c7 100644 --- a/en/sandbox/scenarios.mdx +++ b/en/sandbox/scenarios.mdx @@ -6,7 +6,7 @@ mode: 'wide' ## How to use -Add the header `X-Sandbox-Scenario: ` to any cash-in or cash-out call. Without the header, the sandbox uses the `success` scenario by default. +Add the `X-Sandbox-Scenario: ` header to any cash-in or cash-out call. Without the header, the sandbox uses the `success` scenario by default. ```bash curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ @@ -21,104 +21,87 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ ``` - Most scenarios control the **asynchronous webhook**: the HTTP response is `201 Created` with `status: PENDING`, and the final outcome (`CONFIRMED`, `FAILED`, or `RETURNED`) arrives in the webhook. The exceptions are `timeout` and `provider_5xx`, which fail on the **synchronous** HTTP response itself. + Most scenarios control the **asynchronous webhook**: the HTTP response is `201 Created` with `status: PENDING`, and the final result arrives via webhook. The exceptions are `timeout` and `provider_5xx`, which fail in the **synchronous** HTTP response. ## Available scenarios The canonical scenario values are: `success`, `pending_long`, `rejected`, `returned`, `insufficient_funds`, `bad_clabe`, `timeout`, `provider_5xx`. -### Asynchronous outcome scenarios +### Asynchronous result scenarios -These return `201 PENDING` synchronously; the final state arrives via webhook. +Return `201 PENDING` synchronously; the final state arrives via webhook within seconds. -| Header Value | Webhook outcome | Notes | +| Header Value | Resulting webhook | Notes | |---|---|---| -| `success` (default) | `CONFIRMED` in ~1s | Also used when no header is sent | -| `pending_long` | `CONFIRMED` after ~30s | Tests slow settlement | -| `rejected` | `FAILED` | The SPEI network rejected the transfer | -| `returned` | `RETURNED` | Accepted, then reversed by the counterpart bank | -| `insufficient_funds` | `FAILED` with `errorCode: INSUFFICIENT_FUNDS` | **Cash-out only** | -| `bad_clabe` | `RETURNED` with `errorCode: INVALID_CLABE` | **Cash-out only** — accepted, then returned | +| `success` (default) | `*.settled` — `status: LIQUIDATED` | Also used when no header is sent | +| `pending_long` | `*.settled` — `status: LIQUIDATED` after ~30s | Tests slow settlement | +| `rejected` | `*.rejected` — `status: REJECTED` | The SPEI network rejected the transfer | +| `returned` | `*.returned` — `status: RETURNED` | Accepted and later returned by the counterparty | +| `insufficient_funds` | `cash_out.rejected` — `status: REJECTED` | **Cash-out only** — simulates rejection due to insufficient funds | +| `bad_clabe` | `cash_out.returned` — `status: RETURNED` | **Cash-out only** — accepted and returned due to an invalid CLABE | + + + `*.returned` events are delivered to the **`refund_in`/`refund_out`** webhook type (or `all`), not to `cash_in`/`cash_out`. To test the `returned` and `bad_clabe` scenarios, register those webhooks as well — see [webhook types](/en/guides/webhooks/overview). + ### Synchronous error scenarios -These fail on the HTTP response itself — no webhook is sent. +Fail in the HTTP response itself — no webhook is sent. | Header Value | Synchronous response | |---|---| -| `timeout` | Upstream timeout (`504`) after ~16s | +| `timeout` | Processing timeout (`504`) after ~16s | | `provider_5xx` | Service temporarily unavailable (`503`) | - **Cash-in restrictions:** `insufficient_funds` and `bad_clabe` do not apply to cash-in (there's no balance to debit, and the deposit CLABE is system-generated). Sending either on a cash-in returns `400` with code `SCENARIO_NOT_APPLICABLE`. + **Cash-in restrictions:** `insufficient_funds` and `bad_clabe` do not apply to cash-in (there is no balance to debit, and the deposit CLABE is generated by the system). Sending either of them on a cash-in returns `400` with code `SCENARIO_NOT_APPLICABLE`. ## Example: success webhook +`success` scenario on a cash-out — the `cash_out` webhook receives: + ```json { - "event": "cash_out", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:00:00.000Z", - "transaction": { - "id": 12345, - "externalId": "test-success-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T09:59:59.000Z", - "confirmedAt": "2026-03-26T10:00:00.000Z", - "counterpart": { - "name": "Maria Lopez", - "taxId": null, - "bank": {} - } - }, - "errorCode": null, - "errorMessage": null, - "metadata": {} + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 15000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260326G7H8I9", + "occurredAt": "2026-03-26T10:00:00.000Z" } ``` -## Example: failure webhook +## Example: rejection webhook + +`insufficient_funds` scenario — the `cash_out` webhook receives: ```json { - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:01:00.000Z", - "transaction": { - "id": 12346, - "externalId": "test-error-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "FAILED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": null, - "confirmedAt": null - }, - "errorCode": "INSUFFICIENT_FUNDS", - "errorMessage": "Account without sufficient balance", - "metadata": {} + "event": "transaction.cash_out.rejected", + "transactionId": "2b4c8d9e-3f56-4a1b-bc29-88a3b4c5d6f7", + "amount": 15000, + "currency": "MXN", + "status": "REJECTED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": null, + "voucher": null, + "occurredAt": "2026-03-26T10:01:00.000Z" } ``` -Notes: - -- On `status: FAILED`, `referenceNumerical` and `confirmedAt` are `null` — the SPEI network never confirmed the transaction. -- `errorCode` and `errorMessage` describe the reason for the failure. +With `status: REJECTED`, the receipt fields (`reference`, `voucher`) come back `null` — the SPEI network never confirmed the transaction. The full payload format is in the [Webhooks Overview](/en/guides/webhooks/overview). ## Restrictions - The `X-Sandbox-Scenario` header works **exclusively** on sandbox accounts. -- Production accounts sending the header receive: +- Production accounts that send the header receive: ```json { @@ -129,7 +112,7 @@ Notes: ## Best practices -1. **Test every scenario** before going live — implement handling for `CONFIRMED`, `PENDING`, `FAILED`, and `EXPIRED`. -2. **Validate the error fields** — use `errorCode` for automated decisions; keep `errorMessage` for logs/users. -3. **Test with delay** — make sure your system handles slow webhook delivery well. -4. **Idempotency** — use `transaction.id` as the idempotency key; the same webhook can be re-delivered. +1. **Test every scenario** before going live — implement handling for all four statuses (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`). +2. **Route by the `event` field** — `*.settled`, `*.rejected`, and `*.returned` require different actions in your system. +3. **Test with delay** — use `pending_long` to verify that your system handles slow settlement gracefully. +4. **Idempotency** — deduplicate by the `x-event-id` header; the same event may be redelivered. diff --git a/en/sandbox/webhooks.mdx b/en/sandbox/webhooks.mdx index 6d5d69e..7acc4b2 100644 --- a/en/sandbox/webhooks.mdx +++ b/en/sandbox/webhooks.mdx @@ -1,58 +1,64 @@ --- -title: 'Webhooks' -description: 'How the sandbox delivers webhooks and how to test dedupe, retries, and signature.' +title: 'Simulated webhooks' +description: 'How to trigger each event in the sandbox and validate dedupe, retries, and signature.' mode: 'wide' --- ## How it works -The sandbox uses the **same outbox engine** as production. That means: +The sandbox uses the **same delivery engine** as production: -- Same payload structure -- Same headers (`X-NTXPay-Delivery`, `X-NTXPay-Signature`, etc.) -- Same exponential retry policy +- Same payload structure — see the [full contract](/en/guides/webhooks/overview) +- Same headers (`x-event-id`, `X-NTXPay-Signature`) +- Same retry policy (5 attempts, exponential backoff, 10s timeout) - Same HMAC signature format -The only difference is **speed**: sandbox webhooks fire ~1 second after the request (vs. minutes in production), and you can force artificial delays via the `delayed:` scenario. +The difference is the **origin**: instead of waiting for real settlement on the SPEI network, the simulator resolves the transaction in seconds — and you control the outcome via [scenarios](/en/sandbox/scenarios). -## Register URL +[Webhook configuration](/en/guides/webhooks/setup) is identical to production — register your URL via `POST /api/webhooks-config` as usual. + +## Two ways to trigger a webhook + +### 1. Test webhook (no transaction) + +The fastest way to validate your endpoint — it fires a signed delivery without moving anything: ```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://my-server.com/webhooks/ntxpay", - "events": ["cash_in"] + "eventType": "cash_in", + "status": "LIQUIDATED" }' ``` -### Response (201) +The response tells you immediately whether your endpoint returned `2xx`, the response time, and the signature that was sent. Vary the `status` (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`) to exercise each path in your handler. Field details in [Setup](/en/guides/webhooks/setup#test-webhook). -```json -{ - "id": "wh_550e8400", - "url": "https://my-server.com/webhooks/ntxpay", - "events": ["cash_in"], - "secret": "whsec_a1b2c3d4...", - "createdAt": "2026-03-26T09:00:00.000Z" -} -``` +### 2. Simulated transaction (full flow) + +Create a [cash-in](/en/guides/spei-cash-in) or [cash-out](/en/guides/spei-cash-out) with the `X-Sandbox-Scenario` header — the entire pipeline runs (balance, fee, statement) and the webhook arrives in seconds with the outcome you chose: -Save the returned `secret` — it is used to verify the HMAC signature. **It is only displayed once.** +| To receive | Use scenario | On webhook type | +|---|---|---| +| `*.settled` (`LIQUIDATED`) | `success` (or no header) | `cash_in` / `cash_out` | +| `*.settled` with ~30s delay | `pending_long` | `cash_in` / `cash_out` | +| `*.rejected` (`REJECTED`) | `rejected` or `insufficient_funds` | `cash_in` / `cash_out` | +| `*.returned` (`RETURNED`) | `returned` or `bad_clabe` | `refund_in` / `refund_out` | -## Available events +See the [full scenario catalog](/en/sandbox/scenarios). -| Event | Fired when | -|---|---| -| `cash_in` | Disposable CLABE receives a (simulated) transfer | -| `cash_out` | SPEI send resolves (confirmed or failed) | -| `refund_in` | Cash-in refund is processed | -| `refund_out` | Cash-out refund is processed | +## Testing dedupe -## Verify the signature +Each delivery carries a unique `x-event-id`. To test your dedupe: -Each webhook arrives with the `X-NTXPay-Signature` header in the `sha256=` format: +1. Configure your handler to return `500` on the first attempt. +2. NTX Pay will deliver the same message again (with the **same** `x-event-id`). +3. Confirm that your system ignores the duplicate and responds `200` on the second attempt. + +## Testing the signature + +Point a test webhook at your endpoint and validate the `X-NTXPay-Signature` with the `secret` returned at creation: ```python import hmac @@ -67,45 +73,11 @@ def verify(payload_bytes: bytes, signature_header: str, secret: str) -> bool: return hmac.compare_digest(expected, signature_header) ``` -## Test dedupe - -Each delivery has a unique `deliveryId` in the `X-NTXPay-Delivery` header and inside the payload. To test your dedupe: - -1. Make your handler return `500` on the first attempt. -2. NTX Pay will deliver the same message again (with the **same** `deliveryId`). -3. Confirm your system ignores the duplicate and responds `200` on the second attempt. - -## Retry policy - -| Attempt | Delay after previous | -|---|---| -| 1 | immediate | -| 2 | 30s | -| 3 | 2min | -| 4 | 10min | -| 5 | 1h | -| 6 | 6h | -| 7+ | given up | - -Your endpoint must respond `2xx` within **5 seconds** — any `5xx`, timeout, or connection error triggers a retry. - -## Test scenarios - -Force the webhook to come back as `FAILED` or with delay: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: pending_long" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -See [Scenarios](/en/sandbox/scenarios) for the full catalog. +Complete handlers in Node.js, Python, Java, and Go: [Implementation](/en/guides/webhooks/implementation). ## Best practices -1. **Respond 200 before processing** — queue the event in the background; five seconds is the ceiling. -2. **Use `deliveryId` for dedupe** — do not rely on `transaction.id` (retries arrive with the same `transaction.id` but a new `deliveryId` on manual redrive). -3. **Don't depend on order** — webhooks can arrive out of order after retries. -4. **Always verify the signature** — even in sandbox. +1. **Validate the signature** — always, even in the sandbox. +2. **Use `x-event-id` for dedupe** — the same event may be redelivered. +3. **Do not rely on ordering** — webhooks may arrive out of order after retries. +4. **Exercise all four statuses** before going to production — that is what the sandbox is for. diff --git a/es/endpoints/webhooks-config-test.mdx b/es/endpoints/webhooks-config-test.mdx new file mode 100644 index 0000000..930b541 --- /dev/null +++ b/es/endpoints/webhooks-config-test.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /api/webhooks-config/test +--- diff --git a/es/guides/authentication.mdx b/es/guides/authentication.mdx index f9bb92f..2db7730 100644 --- a/es/guides/authentication.mdx +++ b/es/guides/authentication.mdx @@ -1,16 +1,21 @@ --- title: 'Autenticación' -description: 'Certificado X.509 (mTLS) + OAuth 2.0 client_credentials para obtener JWT de acceso' +description: 'Certificado + OAuth 2.0 client_credentials para obtener el JWT de acceso' +mode: 'wide' --- ## Visión General La API NTX Pay México usa autenticación en dos capas: -1. **Certificado X.509 (mTLS)** — entregado por NTX Pay en el onboarding, comprueba la identidad del servidor cliente. -2. **OAuth 2.0 client_credentials** — `clientId` + `clientSecret` entregados en el onboarding, validados junto con el certificado. +1. **Certificado** — entregado por NTX Pay durante el onboarding, comprueba la identidad del servidor cliente. +2. **OAuth 2.0 client_credentials** — `clientId` + `clientSecret` proporcionados en el onboarding, validados en conjunto con el certificado. -La combinación retorna un **JWT** (validez 10 minutos) usado en los demás endpoints como `Authorization: Bearer ...`. +La combinación devuelve un **JWT** (validez de 10 minutos) que se usa en los demás endpoints como `Authorization: Bearer ...`. + + + La autenticación en el **sandbox es idéntica** — lo que cambia es el par certificado + `clientId`/`clientSecret`, que es distinto del de producción. Las credenciales de producción contra `https://sandbox.mx.ntxpay.com` devuelven `401`. + ## Endpoint @@ -23,13 +28,13 @@ X-SSL-Client-Cert: Content-Type: application/json ``` -El `X-SSL-Client-Cert` típicamente lo inyecta NGINX/ALB con el certificado URL-encoded: +El `X-SSL-Client-Cert` es típicamente inyectado por NGINX/ALB con el certificado URL-encoded: ```nginx proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; ``` -En desarrollo, haz el URL-encode manual: +En desarrollo, haz el URL-encode manualmente: ```bash ENCODED_CERT=$(cat client.cert.pem | python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.stdin.read()))") @@ -58,9 +63,9 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ } ``` -## Usando el Token +## Uso del Token -Incluye el `access_token` en todas las requests autenticadas: +Incluye el `access_token` en todas las solicitudes autenticadas: ```bash curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ @@ -69,24 +74,26 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ ## Renovación -El token expira en **10 minutos (600s)**. Repite el paso 1 antes de expirar — no hay refresh token. +El token expira en **10 minutos (600s)**. Repite el paso 1 antes de que expire — no hay refresh token. - No hagas caché del token entre procesos sin mecanismo de invalidación. Bajo alta carga, genera un token por worker y renueva cada ~8 minutos para evitar `401` por expiración. + No hagas caché del token entre procesos sin un mecanismo de invalidación. Bajo alta carga, genera un token por worker y renuévalo cada ~8 minutos para evitar `401` por expiración. ## Errores Comunes | Código | Causa | Solución | |---|---|---| -| `400` | `X-SSL-Client-Cert` ausente | Configura NGINX/ALB para forwardear el certificado | +| `400` | `X-SSL-Client-Cert` ausente | Configura NGINX/ALB para reenviar el certificado | | `400` | PEM malformado | Verifica que el certificado comience con `-----BEGIN CERTIFICATE-----` | -| `401` | `clientId`/`clientSecret` inválido | Revisa las credenciales (sin espacios) | -| `401` | Certificado expirado/revocado | Solicita renovación a NTX Pay | +| `401` | `clientId`/`clientSecret` inválido | Revisa de nuevo las credenciales (sin espacios extra) | +| `401` | Certificado expirado/revocado | Solicita la renovación a NTX Pay | + +## Ejemplos de Código -## Ejemplo Node.js + -```typescript +```typescript Node.js import fs from 'fs'; import axios from 'axios'; @@ -111,13 +118,136 @@ async function getToken(): Promise { } ``` +```python Python +import os +import urllib.parse +import requests + +with open("client.cert.pem", "r") as f: + cert = f.read() +encoded_cert = urllib.parse.quote(cert) + +def get_token() -> str: + resp = requests.post( + "https://sandbox.mx.ntxpay.com/api/auth/token", + json={ + "clientId": os.environ["NTXPAY_CLIENT_ID"], + "clientSecret": os.environ["NTXPAY_CLIENT_SECRET"], + }, + headers={ + "X-SSL-Client-Cert": encoded_cert, + "Content-Type": "application/json", + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json()["access_token"] +``` + +```java Java +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +public class NtxPayAuth { + public static String getToken() throws Exception { + String cert = Files.readString(Path.of("client.cert.pem")); + String encodedCert = URLEncoder.encode(cert, StandardCharsets.UTF_8); + + String body = """ + { + "clientId": "%s", + "clientSecret": "%s" + } + """.formatted( + System.getenv("NTXPAY_CLIENT_ID"), + System.getenv("NTXPAY_CLIENT_SECRET") + ); + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create("https://sandbox.mx.ntxpay.com/api/auth/token")) + .header("X-SSL-Client-Cert", encodedCert) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse resp = HttpClient.newHttpClient() + .send(req, HttpResponse.BodyHandlers.ofString()); + + // Parsea el access_token con la biblioteca JSON de tu preferencia (Jackson, Gson, etc.) + return resp.body(); + } +} +``` + +```go Go +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/url" + "os" +) + +type tokenResponse struct { + AccessToken string `json:"access_token"` +} + +func getToken() (string, error) { + certBytes, err := os.ReadFile("client.cert.pem") + if err != nil { + return "", err + } + encodedCert := url.QueryEscape(string(certBytes)) + + payload, _ := json.Marshal(map[string]string{ + "clientId": os.Getenv("NTXPAY_CLIENT_ID"), + "clientSecret": os.Getenv("NTXPAY_CLIENT_SECRET"), + }) + + req, err := http.NewRequest( + "POST", + "https://sandbox.mx.ntxpay.com/api/auth/token", + bytes.NewReader(payload), + ) + if err != nil { + return "", err + } + req.Header.Set("X-SSL-Client-Cert", encodedCert) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return "", err + } + return tr.AccessToken, nil +} +``` + + + ## Próximos Pasos - - Flujo completo (token → transacción) - - Usa el token para consultar el saldo + Aplica el token Bearer y consulta el saldo de la cuenta + + + Realiza tu primer cobro SPEI diff --git a/es/guides/balance.mdx b/es/guides/balance.mdx index 514f345..11f07c4 100644 --- a/es/guides/balance.mdx +++ b/es/guides/balance.mdx @@ -5,10 +5,10 @@ description: 'Saldo disponible y pendiente de la cuenta en centavos MXN' ## Visión General -El endpoint `GET /api/balance` retorna el saldo de la cuenta autenticada en **centavos MXN** (enteros). Hay dos campos: +El endpoint `GET /api/balance` devuelve el saldo de la cuenta autenticada en **centavos MXN** (enteros). Hay dos campos: -- **`availableCentavos`** — saldo disponible para enviar SPEI cash-out -- **`pendingCentavos`** — saldo bloqueado (cash-out en procesamiento, cash-in confirmando) +- **`availableCentavos`** — saldo disponible para enviar cash-out SPEI +- **`pendingCentavos`** — saldo bloqueado (cash-out en procesamiento, cash-in en confirmación) ## Endpoint @@ -37,23 +37,23 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ } ``` -`4873490` centavos = **$48 734.90 MXN**. +`4873490` centavos = **$48,734.90 MXN**. ## Estructura de la Respuesta - Saldo disponible en centavos MXN. Úsalo para validar antes de un cash-out. + Saldo disponible en centavos MXN. Usa este valor para validar antes de un cash-out. - Saldo pendiente en centavos MXN. Incluye cash-out en procesamiento y cash-in aguardando confirmación final de la liquidación. + Saldo pendiente en centavos MXN. Incluye cash-out en procesamiento y cash-in en espera de la confirmación final de la liquidación. - Siempre `MXN` en el scope México. + Siempre `MXN` en el ámbito México. -## Ejemplo Node.js +## Ejemplo en Node.js ```typescript import axios from 'axios'; @@ -66,13 +66,13 @@ async function getBalance(token: string) { const availableMXN = (data.availableCentavos / 100).toFixed(2); const pendingMXN = (data.pendingCentavos / 100).toFixed(2); - console.log(`Disponible: $${availableMXN} MXN`); - console.log(`Pendiente: $${pendingMXN} MXN`); + console.log(`Available: $${availableMXN} MXN`); + console.log(`Pending: $${pendingMXN} MXN`); return data; } ``` -## Validación Antes de Cash-Out +## Validación Antes del Cash-Out ```typescript async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) { @@ -80,8 +80,8 @@ async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) if (balance.availableCentavos < amountCentavos) { throw new Error( - `Saldo insuficiente: disponible ${balance.availableCentavos}, ` + - `solicitado ${amountCentavos} (en centavos)`, + `Insufficient balance: available ${balance.availableCentavos}, ` + + `requested ${amountCentavos} (in centavos)`, ); } @@ -92,7 +92,7 @@ async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) ``` - Aún validando el saldo antes, el cash-out puede fallar con `400` si otro cash-out simultáneo consume el saldo. Trata el error `400` como "saldo insuficiente" en el momento de la llamada. + Aun validando el saldo antes, el cash-out puede fallar con `400` si otro cash-out concurrente consume el saldo. Trata el error `400` como "saldo insuficiente" en el momento de la llamada. ## Códigos de Respuesta @@ -101,7 +101,7 @@ async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) |---|---| | `200` | Saldo consultado | | `401` | Token inválido o ausente | -| `502` | `account-ms` no disponible | +| `502` | Servicio temporalmente no disponible — intenta de nuevo | ## Próximos Pasos diff --git a/es/guides/get-started.mdx b/es/guides/get-started.mdx index b083e3f..f1b4855 100644 --- a/es/guides/get-started.mdx +++ b/es/guides/get-started.mdx @@ -1,20 +1,21 @@ --- -title: 'Get Started' +title: 'Primeros Pasos' description: 'Bienvenido a la API NTX Pay México. Esta guía te lleva por los primeros pasos para integrar y comenzar a usar SPEI.' +mode: 'wide' --- -## Overview +## Visión General -La API NTX Pay México permite que tu empresa realice operaciones de pago, consulte saldos, reconcilie transacciones y reciba notificaciones por webhooks — todo de forma segura y escalable. La autenticación es vía **certificado X.509 (mTLS) + OAuth 2.0**, devolviendo un **JWT** de corta duración para cada request. +La API NTX Pay México permite que tu empresa realice operaciones de pago, concilie transacciones y reciba notificaciones por webhooks — todo de forma segura y escalable. La autenticación es vía **certificado X.509 (mTLS) + OAuth 2.0**, y devuelve un **JWT** de corta duración para cada solicitud. -## Getting Started with Sandbox +## Paso a Paso en el Sandbox - - Contacta a tu account manager o escribe a `contact@ntxpay.com` para solicitar una cuenta de sandbox. + + Contacta a tu gerente de cuenta o escribe a `contact@ntxpay.com` solicitando una cuenta sandbox. - Recibirás un **certificado** y las **credenciales OAuth** (`clientId` + `clientSecret`) asociadas a la cuenta de sandbox. + Recibirás un **certificado** y las **credenciales OAuth** (`clientId` + `clientSecret`) asociadas a la cuenta sandbox. Registra la URL de tu webhook vía `POST /api/webhooks-config` para recibir eventos simulados. @@ -24,16 +25,13 @@ La API NTX Pay México permite que tu empresa realice operaciones de pago, consu -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://mi-servidor.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"] }' ``` - - En el dashboard, ve a **Settings** para confirmar que tu cuenta está configurada en modo sandbox. - - - Autentica, realiza las llamadas a la API y usa el header `X-Sandbox-Scenario` para simular diferentes resultados (confirmación inmediata, falla, expiración, etc.). + + Autentícate, haz llamadas a la API y usa el header `X-Sandbox-Scenario` para simular diferentes resultados (confirmación inmediata, falla, expiración, etc.). ```bash curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ @@ -50,13 +48,29 @@ La API NTX Pay México permite que tu empresa realice operaciones de pago, consu -## Ambientes +## Ambiente | Ambiente | URL | |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | -| Producción | Provista en el onboarding | + +## Ruta de Integración + + + + Certificado + OAuth 2.0 para obtener el JWT + + + Recibe pagos vía CLABE desechable + + + Recibe notificaciones de cada evento + + + Escenarios de prueba y webhooks simulados + + ## Soporte -`contact@ntxpay.com` · `https://app.ntxpay.com` +`suporte@ntxpay.com` diff --git a/es/guides/postman-collections.mdx b/es/guides/postman-collections.mdx index 7a494b2..2c77529 100644 --- a/es/guides/postman-collections.mdx +++ b/es/guides/postman-collections.mdx @@ -1,27 +1,19 @@ --- -title: 'Postman Collections' +title: 'Colecciones Postman' description: 'Importa la colección oficial de Postman para probar la API NTX Pay localmente.' +mode: 'wide' --- -## Descarga +## Cómo obtener la colección -Mantenemos una colección de Postman lista para usar con todos los endpoints públicos de la API NTX Pay México. - - - - Colección Postman v2.1 - - - Sandbox y Producción - - +La colección oficial de Postman con todos los endpoints públicos de NTX Pay México se distribuye bajo demanda. Solicita el archivo `.json` (Postman v2.1) por correo a `suporte@ntxpay.com`. ## Importar en Postman 1. Abre Postman 2. **File → Import** -3. Selecciona el archivo `.json` descargado -4. Importa también el zip de environments y elige **Sandbox** para iniciar las pruebas +3. Selecciona el archivo `.json` recibido +4. Importa también el environment proporcionado y elige **Sandbox** para iniciar las pruebas ## Configurar Variables del Environment @@ -31,24 +23,22 @@ El environment espera 3 variables: |---|---|---| | `clientId` | OAuth client_id de tu cuenta | `qr-93-550e8400` | | `clientSecret` | OAuth client_secret | `a1b2c3d4...` | -| `certificatePem` | Certificado X.509 en una sola línea (sin saltos de línea, con `\n` literal) | `-----BEGIN CERTIFICATE-----\n...` | +| `certificatePem` | Certificado X.509 en una sola línea (sin saltos, con `\n` literal) | `-----BEGIN CERTIFICATE-----\n...` | ## Flujo Sugerido -La colección está organizada en el orden de uso típico: +La colección está organizada en el orden típico de uso: -1. **Auth → Generate Token** — ejecuta primero. El response guarda automáticamente el `access_token` en una variable de environment. -2. **Balance → Get Balance** — verifica que el token funcione. -3. **SPEI → Cash-In** — crea un cobro simulado. -4. **Transactions → List** — consulta el estado. +1. **Auth → Generate Token** — ejecútalo primero. El response guarda automáticamente el `access_token` en una variable de environment. +2. **SPEI → Cash-In** — crea un cobro simulado para confirmar que el token está funcionando. +3. **Webhooks Config → Test** — dispara un webhook de prueba en tu endpoint. ## Carpetas Disponibles - `Auth` — generación de JWT +- `SPEI` — cash-in, cash-out - `Balance` — consulta de saldo -- `SPEI` — cash-in, cash-out, transaction by external ID -- `Transactions` — listar -- `Webhooks Config` — listar, crear, eliminar +- `Webhooks Config` — listar, crear, probar, eliminar ## Alternativas @@ -57,3 +47,7 @@ La colección está organizada en el orden de uso típico: - **Insomnia**: importa el mismo `.json` (compatible con Postman v2.1) - **OpenAPI / Bruno**: usa nuestro [`openapi.json`](/api-reference/openapi.json) directamente - **cURL**: cada página de endpoint en esta documentación tiene un ejemplo cURL listo para copiar + +## Soporte + +`suporte@ntxpay.com` diff --git a/es/guides/spei-cash-in.mdx b/es/guides/spei-cash-in.mdx index 83b0265..30f321f 100644 --- a/es/guides/spei-cash-in.mdx +++ b/es/guides/spei-cash-in.mdx @@ -1,17 +1,17 @@ --- title: 'SPEI Cash-In' -description: 'Recibe pagos SPEI vía CLABE desechable (one-time)' +description: 'Recibe pagos SPEI vía CLABE desechable de un solo uso' --- ## Visión General -El **SPEI cash-in** genera una **CLABE desechable** que el pagador usa para realizar una transferencia SPEI desde su app bancaria. Cuando NTX Pay recibe la liquidación, la transacción pasa a `CONFIRMED` y dispara el webhook `cash_in`. +El **cash-in SPEI** genera una **CLABE desechable** que el pagador usa para hacer una transferencia SPEI desde la app de su banco. Cuando NTX Pay recibe la liquidación, se te notifica en el webhook `cash_in` con el evento `transaction.cash_in.settled`. Características: -- CLABE válida para **una sola** transferencia (one-time use) +- CLABE válida para una **única** transferencia (un solo uso) - Confirmación **asíncrona** (segundos a minutos) -- Expira en fecha configurable (default ~24 horas) +- Expira en una fecha configurable (por defecto ~24 horas) ## Endpoint @@ -33,7 +33,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ -d '{ "amountCentavos": 50000, "externalId": "order-abc-123", - "description": "Pedido #123", + "description": "Order #123", "customerName": "Juan Perez", "customerEmail": "juan@example.com", "customerTaxId": "PEPJ800101ABC" @@ -58,10 +58,12 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ } ``` +Muestra la `destinationClabe` (y/o la `checkoutUrl`) al pagador final. + ## Campos del Request - Valor en centavos MXN (mínimo 1). Ej.: `50000` = $500.00 MXN. + Monto en centavos MXN (mínimo 1). Ej.: `50000` = $500.00 MXN. @@ -77,7 +79,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ - Email del pagador (formato de email válido). + Correo electrónico del pagador (formato de e-mail válido). @@ -90,35 +92,50 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ sequenceDiagram participant App as Tu aplicación participant NTX as NTX Pay - participant Pagador - participant Banco as Banco del pagador + participant Payer as Pagador + participant Bank as Banco del pagador App->>NTX: POST /api/spei/cash-in NTX-->>App: 201 + destinationClabe - App->>Pagador: Muestra CLABE (y/o checkoutUrl) - Pagador->>Banco: Transfiere SPEI a destinationClabe - Banco-->>NTX: Liquidación SPEI - NTX->>App: webhook cash_in (CONFIRMED) + App->>Payer: Muestra CLABE (y/o checkoutUrl) + Payer->>Bank: Transferencia SPEI a destinationClabe + Bank-->>NTX: Liquidación SPEI + NTX->>App: webhook cash_in (transaction.cash_in.settled) ``` ## Estados de la Transacción | Status | Significado | |---|---| -| `PENDING` | CLABE emitida, esperando transferencia | +| `PENDING` | CLABE emitida, en espera de la transferencia | | `CONFIRMED` | Transferencia recibida y liquidada | -| `FAILED` | Error en el procesamiento | -| `EXPIRED` | CLABE expiró sin recibir transferencia | +| `FAILED` | Error de procesamiento | +| `EXPIRED` | La CLABE expiró sin recibir la transferencia | + +En el webhook, la liquidación llega como `transaction.cash_in.settled` con `status: LIQUIDATED` — consulta el [payload completo](/es/guides/webhooks/cash-in). ## Idempotencia -Reenvía la misma request con el mismo `externalId` para garantizar que un fallo de red no genere dos cobros. En caso de duplicidad, NTX Pay retorna el cobro existente. +Reenvía la misma solicitud con el mismo `externalId` para garantizar que una falla de red no genere dos cobros. En caso de duplicación, NTX Pay devuelve el cobro existente. + +## Probar en el Sandbox + +En el sandbox, la liquidación se simula en segundos — sin depender de un banco emisor. Controla el desenlace con el header `X-Sandbox-Scenario`: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Sandbox-Scenario: rejected" \ + ... +``` + +Consulta el [catálogo de escenarios](/es/sandbox/scenarios) para forzar rechazo, devolución y retraso. ## Próximos Pasos - Detalles del payload del webhook de confirmación + Payload del webhook de confirmación Envía transferencias SPEI diff --git a/es/guides/spei-cash-out.mdx b/es/guides/spei-cash-out.mdx index aef1cd8..e29ee53 100644 --- a/es/guides/spei-cash-out.mdx +++ b/es/guides/spei-cash-out.mdx @@ -5,7 +5,7 @@ description: 'Envía transferencias SPEI a cualquier CLABE' ## Visión General -El **SPEI cash-out** envía una transferencia interbancaria a una **CLABE de destino**. El saldo de la cuenta se debita y NTX Pay procesa la transferencia sobre la red SPEI. La confirmación llega vía webhook `cash_out`. +El **cash-out SPEI** envía una transferencia interbancaria a una **CLABE de destino**. El saldo de la cuenta se debita y NTX Pay procesa la transferencia en la red SPEI. La confirmación llega en el webhook `cash_out` con el evento `transaction.cash_out.settled`. ## Endpoint @@ -29,7 +29,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ "destinationClabe": "012180001234567890", "beneficiaryName": "Maria Lopez", "beneficiaryTaxId": "LOMA850101ABC", - "concept": "Pago factura 123" + "concept": "Invoice 123 payment" }' ``` @@ -49,11 +49,11 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ ## Campos del Request - Valor en centavos MXN (mínimo 1). Ej.: `50000` = $500.00 MXN. + Monto en centavos MXN (mínimo 1). Ej.: `50000` = $500.00 MXN. - CLABE destino — **exactamente 18 dígitos numéricos** (regex: `^\d{18}$`). + CLABE de destino — **exactamente 18 dígitos numéricos** (regex: `^\d{18}$`). @@ -65,7 +65,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - Concepto que aparece en el extracto del beneficiario (hasta 255 caracteres). + Concepto mostrado en el estado de cuenta del beneficiario (hasta 255 caracteres). ## Validación de Saldo @@ -75,21 +75,25 @@ Antes de enviar, valida el saldo: ```typescript const balance = await getBalance(token); if (balance.availableCentavos < amountCentavos) { - throw new Error('Saldo insuficiente'); + throw new Error('Insufficient balance'); } ``` - Saldo insuficiente retorna `400` — la transacción **no se crea**. Aplica idempotencia en el cliente (no reproceses el mismo pedido tras `400` sin revalidar el saldo). + Saldo insuficiente devuelve `400` — la transacción **no se crea**. Aplica idempotencia del lado del cliente (no reproceses la misma orden tras un `400` sin revalidar el saldo). ## Estados | Status | Significado | |---|---| -| `PENDING` | Cash-out aceptado, esperando liquidación SPEI | -| `CONFIRMED` | Liquidado en Banxico | -| `FAILED` | Rechazado por la red SPEI | +| `PENDING` | Cash-out aceptado, en espera de la liquidación SPEI | +| `CONFIRMED` | Liquidado en la red SPEI | +| `FAILED` | Rechazado por la red SPEI — el saldo debitado se devuelve | + +En el webhook, los desenlaces llegan como `transaction.cash_out.settled` (`LIQUIDATED`) y `transaction.cash_out.rejected` (`REJECTED`) — consulta el [payload completo](/es/guides/webhooks/cash-out). + +**Devolución después de la liquidación:** si el banco de la contraparte devuelve la transferencia, el saldo se acredita de vuelta y recibes `transaction.cash_out.returned` en el webhook [`refund_out`](/es/guides/webhooks/refund-out). ## Códigos de Error @@ -97,9 +101,9 @@ if (balance.availableCentavos < amountCentavos) { |---|---| | `400` | Saldo insuficiente, CLABE inválida, payload inválido | | `401` | Token inválido | -| `502` | Servicio temporalmente no disponible — no reenvíes sin checar status en `GET /api/transactions` | +| `502` | Falla temporal en el procesamiento — no reintentes sin confirmar el desenlace de la transacción original (espera el webhook) | -## Ejemplo Node.js con Retry +## Ejemplo en Node.js con Retry ```typescript async function speiCashOut(token: string, dto: any) { @@ -112,18 +116,25 @@ async function speiCashOut(token: string, dto: any) { return data; // status: PENDING } catch (err) { if (err.response?.status === 502) { - // No sabemos si la transacción fue creada. Consulta /api/transactions filtrando por externalId - // antes de hacer retry. + // No sabemos si la transacción fue creada. Espera el webhook (o contacta a + // soporte) antes de reintentar — un retry a ciegas puede duplicar el envío. } throw err; } } ``` +## Probar en el Sandbox + +En el sandbox corre el pipeline completo — saldo debitado, tarifa cobrada, estado de cuenta generado — y la liquidación se simula en segundos. Tu cuenta necesita saldo: haz un [cash-in](/es/guides/spei-cash-in) antes. Fuerza rechazo, devolución y fallas síncronas con el header `X-Sandbox-Scenario` — consulta el [catálogo de escenarios](/es/sandbox/scenarios). + ## Próximos Pasos - Detalles del payload del webhook de liquidación + Payload del webhook de liquidación + + + Cómo llegan las devoluciones de cash-out diff --git a/es/guides/webhooks/cash-in.mdx b/es/guides/webhooks/cash-in.mdx index 5255b05..af9a133 100644 --- a/es/guides/webhooks/cash-in.mdx +++ b/es/guides/webhooks/cash-in.mdx @@ -1,48 +1,55 @@ --- -title: 'Evento cash_in' -description: 'Notificación enviada cuando un SPEI cash-in es confirmado' +title: 'Webhook cash_in' +description: 'Notificaciones del ciclo de vida de un SPEI cash-in' +mode: 'wide' --- -## Cuándo dispara +## Cuándo se dispara -El evento `cash_in` se dispara cuando: +El webhook del tipo `cash_in` recibe los eventos del ciclo de vida de un cobro creado vía `POST /api/spei/cash-in`: -- Una transferencia SPEI llega a la **CLABE desechable** emitida por `POST /api/spei/cash-in` y es liquidada +| `event` | `status` | Significado | +|---|---|---| +| `transaction.cash_in.settled` | `LIQUIDATED` | La transferencia SPEI llegó a la CLABE desechable y fue liquidada | +| `transaction.cash_in.rejected` | `REJECTED` | La red SPEI rechazó la transferencia | +| `transaction.cash_in.pending` | `PENDING` | Actualización intermedia de procesamiento | + + + La **devolución** de un cash-in ya liquidado (`transaction.cash_in.returned`) se entrega en el webhook del tipo [`refund_in`](/es/guides/webhooks/refund-in), no en el `cash_in`. + ## Payload ```json { - "event": "cash_in", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-05-12T14:31:05.000Z", - "transaction": { - "id": 12345, - "externalId": "order-abc-123", - "paymentMethod": "SPEI", - "direction": "in", - "type": "cash_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-12T14:30:00.000Z", - "confirmedAt": "2026-05-12T14:31:05.000Z" - } + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" } ``` +- `destinationClabe` — la CLABE desechable emitida al crear el cobro +- `sourceClabe` — la CLABE del pagador (cuando la red la informa) +- `amount` — monto en centavos MXN +- Los campos de referencia (`reference`, `voucher`) pueden venir `null` dependiendo del flujo + ## Headers | Header | Valor | |---|---| -| `X-NTXPay-Event` | `cash_in` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch (segundos) | -| `X-NTXPay-Delivery` | UUID único del envío | +| `x-event-id` | UUID único del evento (úsalo para dedupe) | +| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo | ## Respuesta Esperada -Responde `200 OK` en menos de 10 segundos. Ante cualquier status distinto, NTX Pay reintenta hasta 5 veces en backoff exponencial. +Responde `200 OK` en menos de 10 segundos. Ante cualquier status distinto de `2xx`, NTX Pay reintenta hasta 5 veces con backoff exponencial. ```http HTTP/1.1 200 OK @@ -51,35 +58,15 @@ Content-Type: application/json {"received": true} ``` -## Ejemplo de Handler (Node.js / Express) - -```typescript -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body para HMAC +## Procesamiento -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; +Marca la orden como pagada solamente cuando `event` sea `transaction.cash_in.settled` (o `status: LIQUIDATED`): -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) // req.body es Buffer - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'cash_in' && event.transaction.status === 'CONFIRMED') { - enqueue(event); // procesa async - } - - res.json({ received: true }); -}); +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.settled') { + await markOrderPaid(event.transactionId, event.amount); +} ``` -Ver el [guía de implementación](/es/guides/webhooks/implementation) para Python y PHP. +Para el handler completo con validación HMAC y dedupe en Node.js, Python, Java y Go, consulta [Implementación](/es/guides/webhooks/implementation). diff --git a/es/guides/webhooks/cash-out.mdx b/es/guides/webhooks/cash-out.mdx index 9547199..ac0f07a 100644 --- a/es/guides/webhooks/cash-out.mdx +++ b/es/guides/webhooks/cash-out.mdx @@ -1,79 +1,68 @@ --- -title: 'Evento cash_out' -description: 'Notificación enviada cuando un SPEI cash-out es liquidado o falla' +title: 'Webhook cash_out' +description: 'Notificaciones del ciclo de vida de un SPEI cash-out' +mode: 'wide' --- -## Cuándo dispara +## Cuándo se dispara -El evento `cash_out` se dispara en dos escenarios: +El webhook del tipo `cash_out` recibe los eventos del ciclo de vida de una transferencia creada vía `POST /api/spei/cash-out`: -- **Éxito** — el SPEI cash-out enviado vía `POST /api/spei/cash-out` fue liquidado en Banxico (`status: CONFIRMED`) -- **Falla** — la red SPEI rechazó la transferencia (`status: FAILED`) +| `event` | `status` | Significado | +|---|---|---| +| `transaction.cash_out.settled` | `LIQUIDATED` | La transferencia fue liquidada en la red SPEI | +| `transaction.cash_out.rejected` | `REJECTED` | La red SPEI rechazó la transferencia — el saldo debitado se devuelve | +| `transaction.cash_out.pending` | `PENDING` | Actualización intermedia de procesamiento | -## Payload (confirmado) + + La **devolución** de un cash-out ya liquidado (`transaction.cash_out.returned`) — cuando el banco de la contraparte devuelve la transferencia — se entrega en el webhook del tipo [`refund_out`](/es/guides/webhooks/refund-out), no en el `cash_out`. + -```json -{ - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-05-13T12:00:42.000Z", - "transaction": { - "id": 56789, - "externalId": "payout-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": "2026-05-13T12:00:42.000Z" - } -} -``` - -## Payload (falla) +## Payload ```json { - "event": "cash_out", - "deliveryId": "...", - "createdAt": "2026-05-13T12:01:00.000Z", - "transaction": { - "id": 56789, - "status": "FAILED", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": null - } + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260513D4E5F6", + "occurredAt": "2026-05-13T12:00:42.000Z" } ``` -Cuando `status: FAILED`, el saldo bloqueado se devuelve automáticamente. +En `status: REJECTED`, los campos de comprobante (`reference`, `voucher`) pueden venir `null` — la red SPEI nunca confirmó la transacción. ## Headers | Header | Valor | |---|---| -| `X-NTXPay-Event` | `cash_out` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch | -| `X-NTXPay-Delivery` | UUID | +| `x-event-id` | UUID único del evento (úsalo para dedupe) | +| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo | ## Comportamiento -- **At-least-once**: puedes recibir `CONFIRMED` más de una vez. Deduplica por `transaction.id`. -- **Falla después del éxito**: no ocurre. Una transacción no cambia de `CONFIRMED` a `FAILED`. -- **Reversa**: si la contraparte (beneficiario) devuelve, recibes un evento `refund_in` separado, con `transaction.type = "refund_in"` linkado por el `externalId`. +- **At-least-once**: puedes recibir el mismo evento más de una vez. Deduplica por `x-event-id`. +- **Falla después del éxito**: no sucede. Una transacción no cambia de `LIQUIDATED` a `REJECTED`. +- **Devolución**: si la contraparte devuelve después de la liquidación, recibes `transaction.cash_out.returned` en el webhook `refund_out`, con el mismo `transactionId`. ## Respuesta Esperada -```http -HTTP/1.1 200 OK +`200 OK` en un máximo de 10 segundos. + +## Procesamiento + +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.settled') { + await markPayoutSettled(event.transactionId); +} else if (event.event === 'transaction.cash_out.rejected') { + await markPayoutFailed(event.transactionId); // saldo devuelto automáticamente +} ``` -Ver el [guía de implementación](/es/guides/webhooks/implementation) para la validación HMAC. +Para el handler completo con validación HMAC y dedupe en Node.js, Python, Java y Go, consulta [Implementación](/es/guides/webhooks/implementation). diff --git a/es/guides/webhooks/implementation.mdx b/es/guides/webhooks/implementation.mdx index f93901c..658279d 100644 --- a/es/guides/webhooks/implementation.mdx +++ b/es/guides/webhooks/implementation.mdx @@ -1,25 +1,28 @@ --- title: 'Implementación de Webhook' -description: 'Validación HMAC y procesamiento idempotente en Node, Python y PHP' +description: 'Validación HMAC y procesamiento idempotente en Node.js, Python, Java y Go' +mode: 'wide' --- ## Principios -Toda implementación de webhook necesita cubrir 3 cosas: +Toda implementación de webhook debe cubrir 3 cosas: -1. **Validación HMAC** con el `secret` recibido en la creación del webhook +1. **Validación HMAC** con el `secret` recibido al crear el webhook 2. **Respuesta rápida** (`200 OK` en ≤10s) -3. **Idempotencia** vía `X-NTXPay-Delivery` o `transaction.id` +3. **Idempotencia** vía header `x-event-id` -## Node.js / Express +## Ejemplos de Código -```typescript + + +```typescript Node.js import express from 'express'; import crypto from 'crypto'; const app = express(); -// CRÍTICO: usar raw body, no JSON parseado, para que el HMAC coincida +// CRITICAL: usa el raw body, no el JSON parseado, para que el HMAC coincida app.use('/webhooks/ntxpay', express.raw({ type: 'application/json' })); const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; @@ -27,7 +30,7 @@ const seen = new Set(); // producción: Redis con TTL app.post('/webhooks/ntxpay', async (req, res) => { const sig = req.header('X-NTXPay-Signature') ?? ''; - const deliveryId = req.header('X-NTXPay-Delivery') ?? ''; + const eventId = req.header('x-event-id') ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', SECRET) @@ -40,21 +43,19 @@ app.post('/webhooks/ntxpay', async (req, res) => { } // Dedupe - if (seen.has(deliveryId)) return res.json({ duplicate: true }); - seen.add(deliveryId); + if (seen.has(eventId)) return res.json({ duplicate: true }); + seen.add(eventId); const event = JSON.parse(req.body.toString()); - // Procesar asíncrono — no bloquear la respuesta + // Procesa async — no bloquees la respuesta enqueue(event).catch(console.error); res.json({ received: true }); }); ``` -## Python / Flask - -```python +```python Python import hmac import hashlib from flask import Flask, request, abort, jsonify @@ -65,103 +66,232 @@ seen = set() # producción: Redis con TTL @app.post('/webhooks/ntxpay') def webhook(): - raw = request.get_data() # bytes crudos + raw = request.get_data() # bytes crudos — esencial para que el HMAC coincida sig = request.headers.get('X-NTXPay-Signature', '') - delivery_id = request.headers.get('X-NTXPay-Delivery', '') + event_id = request.headers.get('x-event-id', '') expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401) - if delivery_id in seen: + if event_id in seen: return jsonify(duplicate=True) - seen.add(delivery_id) + seen.add(event_id) event = request.get_json() - # encolar asíncronamente + # encola de forma asíncrona enqueue(event) return jsonify(received=True) ``` -## PHP +```java Java +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.security.MessageDigest; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@RestController +public class NtxPayWebhook { + private static final byte[] SECRET = + System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); + // producción: Redis con TTL + private final Set seen = ConcurrentHashMap.newKeySet(); + + @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity handle( + @RequestHeader("X-NTXPay-Signature") String sig, + @RequestHeader("x-event-id") String eventId, + @RequestBody byte[] raw // bytes crudos — esencial para que el HMAC coincida + ) throws Exception { + String expected = "sha256=" + hmacSha256Hex(SECRET, raw); + if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { + return ResponseEntity.status(401).build(); + } + if (!seen.add(eventId)) { + return ResponseEntity.ok(Map.of("duplicate", true)); + } + + // encola el procesamiento async + return ResponseEntity.ok(Map.of("received", true)); + } -```php - true]); - exit; -} -$_SESSION['seen'][$deliveryId] = true; + if !hmac.Equal([]byte(sig), []byte(expected)) { + w.WriteHeader(http.StatusUnauthorized) + return + } -$event = json_decode($raw, true); + seenMu.Lock() + if seen[eventID] { + seenMu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]bool{"duplicate": true}) + return + } + seen[eventID] = true + seenMu.Unlock() -// Encolar procesamiento asíncrono -// procesarAsync($event); + // encola el procesamiento async + _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) +} -http_response_code(200); -echo json_encode(['received' => true]); +func main() { + http.HandleFunc("/webhooks/ntxpay", handleWebhook) + _ = http.ListenAndServe(":8080", nil) +} ``` + + ## ¿Por qué raw body? -El HMAC se calcula sobre los **bytes exactos** que NTX Pay envió. Si el framework parsea el JSON antes (reordenando espacios, campos), la firma no coincide. Siempre captura el body crudo en **bytes** antes de hacer parse. +El HMAC se calcula sobre los **bytes exactos** que NTX Pay envió. Si el framework parsea el JSON antes (reacomodando espacios, reordenando campos), la firma no coincide. Captura siempre el body crudo en **bytes** antes de hacer el parse. -## Eventos por Status +## Enrutar por Evento y Status -Filtra antes de procesar: +El campo `event` identifica el flujo (`transaction.cash_in.*` / `transaction.cash_out.*`) y el `status` el resultado. Filtra antes de procesar: -```typescript + + +```typescript Node.js const event = JSON.parse(req.body.toString()); switch (event.event) { - case 'cash_in': - if (event.transaction.status === 'CONFIRMED') { - await marcarPedidoPagado(event.transaction.externalId); - } + case 'transaction.cash_in.settled': + await markOrderPaid(event.transactionId, event.amount); break; - case 'cash_out': - if (event.transaction.status === 'CONFIRMED') { - await marcarPayoutLiquidado(event.transaction.id); - } else if (event.transaction.status === 'FAILED') { - await marcarPayoutFallido(event.transaction.id); - } + case 'transaction.cash_out.settled': + await markPayoutSettled(event.transactionId); + break; + + case 'transaction.cash_out.rejected': + await markPayoutFailed(event.transactionId); break; - case 'refund_in': - case 'refund_out': - await procesarEstorno(event); + case 'transaction.cash_in.returned': + case 'transaction.cash_out.returned': + await processRefund(event); break; } ``` +```python Python +event = request.get_json() + +match event["event"]: + case "transaction.cash_in.settled": + mark_order_paid(event["transactionId"], event["amount"]) + case "transaction.cash_out.settled": + mark_payout_settled(event["transactionId"]) + case "transaction.cash_out.rejected": + mark_payout_failed(event["transactionId"]) + case "transaction.cash_in.returned" | "transaction.cash_out.returned": + process_refund(event) +``` + +```java Java +// `raw` es el byte[] del cuerpo del request, `mapper` es un ObjectMapper de Jackson +Map event = mapper.readValue(raw, new TypeReference<>() {}); +String evtType = (String) event.get("event"); +String txId = (String) event.get("transactionId"); + +switch (evtType) { + case "transaction.cash_in.settled" -> + markOrderPaid(txId, ((Number) event.get("amount")).longValue()); + case "transaction.cash_out.settled" -> markPayoutSettled(txId); + case "transaction.cash_out.rejected" -> markPayoutFailed(txId); + case "transaction.cash_in.returned", "transaction.cash_out.returned" -> + processRefund(event); +} +``` + +```go Go +var event struct { + Event string `json:"event"` + TransactionID string `json:"transactionId"` + Amount int64 `json:"amount"` + Status string `json:"status"` +} +if err := json.Unmarshal(raw, &event); err != nil { + return err +} + +switch event.Event { +case "transaction.cash_in.settled": + markOrderPaid(event.TransactionID, event.Amount) +case "transaction.cash_out.settled": + markPayoutSettled(event.TransactionID) +case "transaction.cash_out.rejected": + markPayoutFailed(event.TransactionID) +case "transaction.cash_in.returned", "transaction.cash_out.returned": + processRefund(event) +} +``` + + + ## Reintentos -Si devuelves status ≠ `2xx`, NTX Pay reintenta hasta **5 veces** en backoff exponencial (~30s, 1m, 5m, 15m, 1h). Tras eso, el evento se descarta. Para reenviar manualmente, usa el panel o contacta soporte. +Si devuelves un status ≠ `2xx` (o excedes el timeout de 10s), NTX Pay reintenta hasta **5 veces** con backoff exponencial a partir de ~5 segundos. Después de eso, la entrega se marca como fallida — el reenvío manual puede solicitarse a soporte. - No uses `429` para señalar rate-limit de tu propio servicio — eso dispara retry y amplifica la carga. Responde `503 Service Unavailable` si realmente no puedes procesar. + No uses `429` para señalar el rate-limit de tu propio servicio — eso activa el retry y amplifica la carga. Responde `503 Service Unavailable` si realmente no puedes procesar. ## Buenas Prácticas -- **Usa Redis/DB para dedupe** con TTL ≥ 24h (no memoria in-process) -- **Procesa asíncrono**: el webhook handler solo valida + encola -- **Monitorea latencia** del handler — meta P95 < 500ms -- **Loguea `X-NTXPay-Delivery`** para auditoría -- **Re-consulta `/api/transactions`** si el webhook trae estado conflictivo con tu DB +- **Usa Redis/base de datos para el dedupe** con TTL ≥ 24h (no memoria in-process) +- **Procesa de forma asíncrona**: el webhook handler solo valida + encola +- **Monitorea la latencia** del handler — objetivo P95 < 500ms +- **Registra `x-event-id`** en los logs para auditoría diff --git a/es/guides/webhooks/overview.mdx b/es/guides/webhooks/overview.mdx index 5942a18..a747f52 100644 --- a/es/guides/webhooks/overview.mdx +++ b/es/guides/webhooks/overview.mdx @@ -1,67 +1,108 @@ --- title: 'Visión General de Webhooks' description: 'Notificaciones automáticas para eventos SPEI' +mode: 'wide' --- ## Qué son los Webhooks -Los webhooks permiten que NTX Pay envíe notificaciones HTTPS a tu servidor siempre que ocurre un evento relevante — confirmación de cash-in, falla de cash-out, etc. — sin que tengas que hacer polling en `GET /api/transactions`. +Los webhooks permiten que NTX Pay envíe notificaciones HTTPS a tu servidor cada vez que ocurre un evento relevante — confirmación de cash-in, falla de cash-out, devolución — sin que tengas que hacer polling. -## Eventos Disponibles +## Tipos de Webhook -| Evento | Cuándo dispara | -|---|---| -| `cash_in` | SPEI cash-in confirmado | -| `cash_out` | SPEI cash-out liquidado | -| `refund_in` | Estorno **recibido** (una transacción cash-out tuya fue devuelta) | -| `refund_out` | Estorno **enviado** (devolviste un cash-in) | -| `internal_transfer` | Transferencia interna entre cuentas NTX Pay | - -## Configuración - -Endpoint: `POST /api/webhooks-config`. Proporcionas: - -- **`url`** — endpoint HTTPS en tu servidor -- **`events`** — array con exactamente 1 evento (un webhook se suscribe a un evento) -- **`secret`** — opcional. Si se omite, NTX Pay genera uno automáticamente y lo retorna en la respuesta. +Al registrar un webhook vía `POST /api/webhooks-config`, eliges **qué tipo de evento** recibe esa URL: -Ver el [guía de Setup](/es/guides/webhooks/setup) para el paso a paso. - -## Seguridad — Firma HMAC - -Todo webhook se firma con HMAC-SHA256 usando el `secret`. Headers enviados: +| Tipo | Recibe | +|---|---| +| `cash_in` | Resultado de cobros SPEI (confirmado, rechazado, pendiente) | +| `cash_out` | Resultado de envíos SPEI (liquidado, rechazado, pendiente) | +| `refund_in` | Devolución de un **cash-in** — un pago que recibiste fue devuelto al pagador | +| `refund_out` | Devolución de un **cash-out** — una transferencia que enviaste fue devuelta por la contraparte | +| `all` | **General** — una única URL que recibe todos los eventos anteriores | + + + Cada webhook se suscribe a **exactamente un** tipo. Para recibir varios tipos en URLs separadas, crea un webhook por tipo — o usa `all` para centralizar todo en una URL y enrutar por el campo `event` del payload. + + +## Payload + +Todo webhook entrega el mismo formato de payload: + +```json +{ + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" +} +``` + + + Tipo del evento: `transaction.cash_in.settled`, `transaction.cash_in.rejected`, `transaction.cash_in.returned`, `transaction.cash_out.settled`, `transaction.cash_out.rejected`, `transaction.cash_out.returned`, o las variantes `.pending`. Usa este campo para enrutar el procesamiento. + + + + Identificador de la transacción. Correlaciónalo con el `id` devuelto al crear el cobro/la transferencia. + + + + Monto en centavos MXN. + + + + `LIQUIDATED` (liquidado), `REJECTED` (rechazado), `RETURNED` (devuelto) o `PENDING` (en procesamiento). + + + + CLABEs de destino y origen de la transferencia. Pueden venir `null` dependiendo del flujo. + + + + Referencia numérica SPEI y comprobante, cuando estén disponibles. + + + + Timestamp ISO 8601 del evento. + + +## Headers | Header | Contenido | |---|---| -| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo | -| `X-NTXPay-Timestamp` | timestamp Unix del envío | -| `X-NTXPay-Event` | nombre del evento (`cash_in`, etc.) | -| `X-NTXPay-Delivery` | UUID único del envío (úsalo para dedupe) | +| `x-event-id` | UUID único del evento — úsalo para **deduplicar** | +| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo, firmado con el `secret` del webhook | +| `Content-Type` | `application/json` | -**Siempre valida la firma** antes de procesar — sin eso, cualquier persona puede falsificar notificaciones. +**Valida siempre la firma** antes de procesar — sin eso, cualquier persona puede falsificar notificaciones. Consulta [Implementación](/es/guides/webhooks/implementation). ## Garantías de Entrega -- **At-least-once**: puedes recibir el mismo evento más de una vez. Usa `X-NTXPay-Delivery` para deduplicar. -- **Retries**: hasta 5 intentos en backoff exponencial si respondes con status distinto de `2xx`. -- **Timeout**: 10 segundos. Responde rápido — procesa asíncronamente si es necesario. -- **Order**: los eventos llegan **fuera de orden** en condiciones de error. Confiere `createdAt` en el payload. +- **At-least-once**: puedes recibir el mismo evento más de una vez. Deduplica por `x-event-id`. +- **Retries**: hasta **5 intentos** con backoff exponencial (a partir de ~5s) si respondes con un status distinto de `2xx`. +- **Timeout**: **10 segundos**. Responde rápido — procesa de forma asíncrona si es necesario. +- **Orden**: los eventos pueden llegar fuera de orden en condiciones de error. Revisa `occurredAt` en el payload. ## Prácticas Recomendadas -1. **Responde `200` inmediatamente** tras validar la firma y encolar el evento. -2. **Deduplica por `X-NTXPay-Delivery`** o `transaction.id`. -3. **Idempotencia**: procesa `cash_in` confirmado para el mismo `externalId` una sola vez. -4. **Re-consulta** `/api/transactions` si tienes duda sobre el estado — el webhook es una optimización, no la fuente de verdad. +1. **Responde `200` inmediatamente** después de validar la firma y encolar el evento. +2. **Deduplica por `x-event-id`**. +3. **Enruta por el campo `event`** — no asumas que una URL recibe un único tipo (especialmente con `all`). +4. **Maneja `status` explícitamente** — implementa los cuatro estados (`LIQUIDATED`, `REJECTED`, `RETURNED`, `PENDING`). 5. **HTTPS obligatorio** — los webhooks solo se envían a URLs con protocolo HTTPS. ## Próximos Pasos - - Configura el endpoint en tu cuenta + + Registra la URL en tu cuenta y dispara un webhook de prueba - Ejemplos en Node, Python y PHP de validación HMAC + Ejemplos en Node.js, Python, Java y Go de validación HMAC diff --git a/es/guides/webhooks/refund-in.mdx b/es/guides/webhooks/refund-in.mdx index a099f79..530e1ab 100644 --- a/es/guides/webhooks/refund-in.mdx +++ b/es/guides/webhooks/refund-in.mdx @@ -1,58 +1,56 @@ --- -title: 'Evento refund_in' -description: 'Notificación de estorno recibido — una transferencia cash-out tuya fue devuelta' +title: 'Webhook refund_in' +description: 'Devolución de un cash-in — un pago recibido fue devuelto al pagador' +mode: 'wide' --- -## Cuándo dispara +## Cuándo se dispara -El evento `refund_in` se dispara cuando una transacción **cash-out enviada por ti** es devuelta por la contraparte. El saldo correspondiente se acredita de vuelta en tu cuenta. +El webhook del tipo `refund_in` recibe el evento `transaction.cash_in.returned`: un **cash-in que recibiste fue devuelto al pagador**. El saldo correspondiente se debita de tu cuenta. Escenarios comunes: -- Beneficiario rechazó la transferencia manualmente -- CLABE existía pero la cuenta fue cerrada tras la confirmación inicial -- Estorno solicitado por el beneficiario dentro del plazo SPEI +- Reverso por motivo de fraude o error +- Devolución dentro del plazo de la red SPEI ## Payload ```json { - "event": "refund_in", - "deliveryId": "5b9c2d8e-4f12-4a18-bb29-88a3b4c5d6f7", - "createdAt": "2026-05-14T09:15:00.000Z", - "transaction": { - "id": 67890, - "externalId": "payout-001-refund", - "paymentMethod": "SPEI", - "direction": "in", - "type": "refund_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T09:14:50.000Z", - "confirmedAt": "2026-05-14T09:15:00.000Z" - }, - "originalTransactionId": 56789 + "event": "transaction.cash_in.returned", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": null, + "occurredAt": "2026-05-14T11:45:00.000Z" } ``` -El campo `originalTransactionId` apunta al `id` del cash-out original que fue estornado. Úsalo para correlacionar. +El `transactionId` es el **mismo** del cash-in original — úsalo para localizar la orden y marcarla como devuelta. + +## Headers + +| Header | Valor | +|---|---| +| `x-event-id` | UUID único del evento (úsalo para dedupe) | +| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo | ## Respuesta Esperada -`HTTP 200 OK` en hasta 10 segundos. +`200 OK` en un máximo de 10 segundos. -## Procesamiento Recomendado +## Procesamiento ```typescript -if (event.event === 'refund_in') { - // Crédito del saldo ya ocurrió automáticamente - await marcarPayoutComoEstornado({ - originalId: event.originalTransactionId, - refundId: event.transaction.id, - valor: event.transaction.amountCentavos, - }); +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.returned') { + // El débito del saldo ya ocurrió automáticamente + await markOrderRefunded(event.transactionId, event.amount); } ``` -Ver el [guía de implementación](/es/guides/webhooks/implementation) para validación HMAC. +Para el handler completo con validación HMAC y dedupe en Node.js, Python, Java y Go, consulta [Implementación](/es/guides/webhooks/implementation). diff --git a/es/guides/webhooks/refund-out.mdx b/es/guides/webhooks/refund-out.mdx index d7c55c1..34cb184 100644 --- a/es/guides/webhooks/refund-out.mdx +++ b/es/guides/webhooks/refund-out.mdx @@ -1,57 +1,57 @@ --- -title: 'Evento refund_out' -description: 'Notificación de estorno enviado — devolviste una transacción cash-in recibida' +title: 'Webhook refund_out' +description: 'Devolución de un cash-out — una transferencia enviada fue devuelta por la contraparte' +mode: 'wide' --- -## Cuándo dispara +## Cuándo se dispara -El evento `refund_out` se dispara cuando un **cash-in que recibiste es devuelto al pagador**. El saldo correspondiente se debita de tu cuenta. +El webhook del tipo `refund_out` recibe el evento `transaction.cash_out.returned`: una **transferencia que enviaste fue devuelta** por el banco de la contraparte. El saldo correspondiente se acredita de vuelta en tu cuenta. Escenarios comunes: -- Accionaste un estorno por motivo de fraude o error -- El cliente solicitó cancelación dentro del plazo SPEI +- CLABE de destino inválida o cuenta cerrada +- El beneficiario/banco de la contraparte rechazó la transferencia después de la aceptación inicial +- Devolución dentro del plazo de la red SPEI ## Payload ```json { - "event": "refund_out", - "deliveryId": "7d2c9e8f-5b34-4c19-aa18-99b3c4d5e6f7", - "createdAt": "2026-05-14T11:45:00.000Z", - "transaction": { - "id": 78901, - "externalId": "order-abc-123-refund", - "paymentMethod": "SPEI", - "direction": "out", - "type": "refund_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T11:44:50.000Z", - "confirmedAt": "2026-05-14T11:45:00.000Z" - }, - "originalTransactionId": 12345 + "event": "transaction.cash_out.returned", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": null, + "occurredAt": "2026-05-14T09:15:00.000Z" } ``` -`originalTransactionId` apunta al `id` del cash-in original que fue devuelto. +El `transactionId` es el **mismo** del cash-out original — úsalo para correlacionar y marcar el pago como devuelto. + +## Headers + +| Header | Valor | +|---|---| +| `x-event-id` | UUID único del evento (úsalo para dedupe) | +| `X-NTXPay-Signature` | `sha256=` del cuerpo crudo | ## Respuesta Esperada -`HTTP 200 OK`. +`200 OK` en un máximo de 10 segundos. -## Procesamiento Recomendado +## Procesamiento ```typescript -if (event.event === 'refund_out') { - // Saldo ya debitado - await marcarPedidoComoEstornado({ - originalCashInId: event.originalTransactionId, - refundId: event.transaction.id, - valor: event.transaction.amountCentavos, - }); +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.returned') { + // El crédito del saldo de vuelta ya ocurrió automáticamente + await markPayoutReturned(event.transactionId, event.amount); } ``` -Ver el [guía de implementación](/es/guides/webhooks/implementation) para validación HMAC. +Para el handler completo con validación HMAC y dedupe en Node.js, Python, Java y Go, consulta [Implementación](/es/guides/webhooks/implementation). diff --git a/es/guides/webhooks/setup.mdx b/es/guides/webhooks/setup.mdx index 7698ce4..8095d82 100644 --- a/es/guides/webhooks/setup.mdx +++ b/es/guides/webhooks/setup.mdx @@ -1,15 +1,17 @@ --- -title: 'Setup de Webhooks' -description: 'Configura URLs de webhook programáticamente para SPEI' +title: 'Configuración de Webhooks' +description: 'Registra, prueba, lista y elimina URLs de webhook programáticamente' +mode: 'wide' --- ## Visión General -La configuración de webhooks se hace vía tres endpoints: +La configuración de webhooks se hace vía cuatro endpoints: - `GET /api/webhooks-config` — listar webhooks activos - `POST /api/webhooks-config` — crear/configurar un webhook -- `DELETE /api/webhooks-config/{id}` — remover un webhook +- `POST /api/webhooks-config/test` — disparar un webhook de prueba firmado +- `DELETE /api/webhooks-config/{id}` — eliminar un webhook ## Crear Webhook @@ -20,7 +22,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://mi-servidor.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "secret": "whsec_abc123def456" }' @@ -31,7 +33,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ ```json { "id": 42, - "url": "https://mi-servidor.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "isActive": true, "secret": "whsec_abc123def456" @@ -39,7 +41,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ ``` - Si omites `secret` en el request, NTX Pay genera uno automáticamente y lo retorna en la respuesta — **guárdalo de inmediato**, no se muestra nuevamente. + Si omites `secret` en el request, NTX Pay lo genera automáticamente y lo devuelve en la respuesta — **guárdalo de inmediato**, no se muestra de nuevo. ### Campos @@ -49,13 +51,57 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ - Un webhook se suscribe a **exactamente UN** evento — el array debe contener un único item. Valores aceptados: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `internal_transfer`. Para recibir más de un tipo de evento, crea un webhook por evento. + Un webhook se suscribe a **exactamente UN** evento — el array debe contener un único elemento. Valores aceptados: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `all` (General — recibe todos los eventos) e `internal_transfer`. Consulta la semántica de cada tipo en la [Visión General](/es/guides/webhooks/overview). - Secret HMAC para validar firma. Mínimo 8 caracteres, máximo 128. Si se omite, NTX Pay lo genera. + Secret HMAC para validar la firma. Mínimo 8 caracteres, máximo 128. Si se omite, NTX Pay lo genera. +## Webhook de Prueba + +Después de crear el webhook, dispara una entrega de prueba **firmada con el mismo secret** — sin necesidad de mover una transacción: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "eventType": "cash_in", + "status": "LIQUIDATED" + }' +``` + +```json +{ + "delivered": true, + "url": "https://meu-servidor.com/webhooks/ntxpay", + "eventId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "status": "LIQUIDATED", + "signed": true, + "statusCode": 200, + "timeMs": 184 +} +``` + + + Qué webhook recibe la prueba: `cash_in`, `cash_out`, `refund_in`, `refund_out` o `internal_transfer`. + + + + Status simulado en el payload: `LIQUIDATED` (default), `PENDING`, `REJECTED` o `RETURNED`. + + + + URL temporal de prueba (ej.: webhook.site). Si se omite, entrega en la URL configurada. + + + + Monto en centavos en el payload de prueba (default `1000` = $10.00 MXN). + + +`delivered: true` significa que tu endpoint respondió `2xx`. `statusCode: 0` indica error de conexión. + ## Listar Webhooks ```bash @@ -69,7 +115,7 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/webhooks-config \ "webhooks": [ { "id": 42, - "url": "https://mi-servidor.com/webhooks/ntxpay", + "url": "https://meu-servidor.com/webhooks/ntxpay", "events": ["cash_in"], "isActive": true, "createdAt": "2026-05-01T10:30:00.000Z" @@ -80,10 +126,10 @@ curl -X GET https://sandbox.mx.ntxpay.com/api/webhooks-config \ ``` - La respuesta del listado **no** incluye el `secret` — solo se muestra al crear. + La respuesta del listado **no** incluye el `secret` — solo se muestra en la creación. -## Remover Webhook +## Eliminar Webhook ```bash curl -X DELETE https://sandbox.mx.ntxpay.com/api/webhooks-config/42 \ @@ -93,34 +139,33 @@ curl -X DELETE https://sandbox.mx.ntxpay.com/api/webhooks-config/42 \ ```json { "success": true, - "message": "Webhook removido exitosamente" + "message": "Webhook removido com sucesso" } ``` ## Múltiples Webhooks -Cada webhook se suscribe a exactamente un evento, así que configura **un webhook por tipo de evento** que quieras recibir (p. ej. uno para `cash_in`, otro para `cash_out`). Útil para: +Cada webhook se suscribe a exactamente un evento, así que tienes dos estrategias: -- Enrutar cada tipo de evento a su propio endpoint/handler -- Ambiente de **homologación interna** vs producción -- Múltiples servicios consumiendo eventos diferentes +- **Un webhook por tipo** (ej.: uno para `cash_in`, otro para `cash_out`) — enruta cada tipo a su propio endpoint/handler. +- **Un webhook `all`** — una URL única recibe todo y tu handler enruta por el campo `event` del payload. -## Probando el Endpoint +## Validar el Endpoint -Antes de configurar en producción, valida tu endpoint: +Antes de liberar el webhook para recibir tráfico real: -1. Configura en sandbox primero -2. Usa [webhook.site](https://webhook.site) o [ngrok](https://ngrok.com) para inspeccionar el tráfico -3. Confirma que tu aplicación: +1. Usa [webhook.site](https://webhook.site) o [ngrok](https://ngrok.com) para inspeccionar el tráfico (el campo `overrideUrl` del webhook de prueba acepta esas URLs) +2. Dispara entregas con `POST /api/webhooks-config/test` variando el `status` +3. Verifica que tu aplicación: - Valida `X-NTXPay-Signature` correctamente - - Retorna `200` en menos de 10 segundos - - Deduplica por `X-NTXPay-Delivery` + - Devuelve `200` en menos de 10 segundos + - Deduplica por `x-event-id` ## Próximos Pasos - Validación HMAC en Node, Python y PHP + Validación HMAC en Node.js, Python, Java y Go Payload de cada tipo de evento diff --git a/es/index.mdx b/es/index.mdx index a4947f3..affd631 100644 --- a/es/index.mdx +++ b/es/index.mdx @@ -3,7 +3,7 @@ title: 'API NTX Pay México' description: 'Integración con SPEI en una única API' --- -Gateway público para integración con SPEI (transferencias interbancarias instantáneas). Recepción y envío vía SPEI, consulta de saldo y transacciones, webhooks firmados. +Gateway público para integración con SPEI (transferencias interbancarias instantáneas). Recepción y envío vía SPEI, consulta de saldo, webhooks firmados. ## Ambientes diff --git a/es/sandbox/authentication.mdx b/es/sandbox/authentication.mdx deleted file mode 100644 index 47e9a13..0000000 --- a/es/sandbox/authentication.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: 'Autenticación' -description: 'La autenticación en sandbox es estructuralmente idéntica a producción.' -mode: 'wide' ---- - -## Visión General - -La autenticación de sandbox usa las mismas dos capas que producción: - -1. **Certificado X.509 (mTLS)** — entregado por NTX Pay en el onboarding. -2. **OAuth 2.0 `client_credentials`** — `clientId` + `clientSecret` entregados en el onboarding. - -En conjunto, devuelven un **JWT** (validez de 10 minutos) usado en los demás endpoints como `Authorization: Bearer ...`. - - - Las credenciales de sandbox son **distintas** de las de producción. Si usas credenciales de producción contra `https://sandbox.mx.ntxpay.com`, recibirás `401`. El contrato HTTP es idéntico — lo que cambia es el par certificado + clientId/clientSecret. - - -## Obtener Token - -### POST /api/auth/token - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ - -H "X-SSL-Client-Cert: $ENCODED_CERT" \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "qr-93-550e8400", - "clientSecret": "a1b2c3d4e5f6g7h8" - }' -``` - -#### Response (201) - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "token_type": "Bearer", - "expires_in": 600, - "scope": "email profile" -} -``` - -## Usar el Token - -En una cuenta sandbox, cualquier llamada autenticada simula el pipeline completo sin mover dinero real: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "externalId": "test-001" - }' -``` - -La respuesta siempre es `201 Created` con `status: PENDING`. El resultado final (confirmación o falla) llega vía webhook tras ~1 segundo. Mira [Escenarios](/es/sandbox/scenarios) para forzar resultados específicos. - -## Renovación - -El token expira en **10 minutos (600s)**. No hay refresh token — genera uno nuevo vía `POST /api/auth/token` antes de que expire. - - - Bajo alta carga, genera un token por worker y renueva cada ~8 minutos para evitar `401` por expiración. - - -## Errores Comunes - -| Código | Causa | Solución | -|---|---|---| -| `400` | `X-SSL-Client-Cert` ausente | Configura NGINX/ALB para reenviar el certificado | -| `401` | `clientId`/`clientSecret` inválido | Verifica las credenciales; confirma que estás usando las de sandbox | -| `401` | Certificado expirado/revocado | Solicita renovación a NTX Pay | - -## Documentación detallada - -Para el paso a paso completo (codificación del certificado, ejemplos en múltiples lenguajes, etc.) mira [Autenticación](/es/guides/authentication) en la guía general — la única diferencia es la base URL `https://sandbox.mx.ntxpay.com`. diff --git a/es/sandbox/cash-in.mdx b/es/sandbox/cash-in.mdx deleted file mode 100644 index 0a7f9fd..0000000 --- a/es/sandbox/cash-in.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: 'Cash-in (recepción SPEI)' -description: 'Cómo generar una CLABE desechable de cobro en sandbox.' -mode: 'wide' ---- - -## Qué hace - -`POST /api/spei/cash-in` genera una **CLABE desechable** vinculada a tu cuenta sandbox. Cualquier transferencia SPEI recibida en esa CLABE dispara un webhook `cash_in` a la URL configurada. - -En sandbox, la confirmación es **simulada** ~1 segundo después de crear la CLABE (en lugar de esperar una transferencia real). Esto permite probar todo el flujo de cash-in sin depender de un banco emisor real. - -## Ejemplo - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 50000, - "externalId": "order-001", - "customerName": "Juan Pérez", - "customerEmail": "juan@example.com" - }' -``` - -### Response (201) - -```json -{ - "id": 12345, - "externalId": "order-001", - "status": "PENDING", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "expiresAt": "2026-03-26T10:30:00.000Z" -} -``` - -Usa la `clabe` devuelta para mostrarla al pagador final (cliente de tu empresa). En sandbox, esta CLABE es ficticia pero el campo `transaction.clabe` que llega en el webhook será **el mismo**. - -## Webhook esperado - -Tras ~1 segundo (escenario `success` default), recibes: - -```json -{ - "event": "cash_in", - "deliveryId": "...", - "transaction": { - "id": 12345, - "externalId": "order-001", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "confirmedAt": "2026-03-26T10:00:01.000Z", - "counterpart": { - "name": "Pagador Simulado", - "taxId": "PAGS850101ABC", - "bank": { - "code": "012", - "name": "BBVA México" - } - } - } -} -``` - -## Escenarios de prueba - -| Escenario | Webhook | -|---|---| -| `success` (default) | `CONFIRMED` | -| `pending_long` | `CONFIRMED` tras ~30s | -| `rejected` | `FAILED` | -| `returned` | `RETURNED` | - -`timeout` y `provider_5xx` fallan en la respuesta HTTP síncrona. `insufficient_funds` y `bad_clabe` **no aplican** a cash-in (devuelven `400 SCENARIO_NOT_APPLICABLE`). - -Mira [Escenarios](/es/sandbox/scenarios) para la lista completa. - -## Próximos pasos - - - - Cómo enviar SPEI en sandbox. - - - Entendiendo la entrega de webhooks en sandbox. - - diff --git a/es/sandbox/cash-out.mdx b/es/sandbox/cash-out.mdx deleted file mode 100644 index 8c386bf..0000000 --- a/es/sandbox/cash-out.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: 'Cash-out (envío SPEI)' -description: 'Cómo enviar SPEI a una CLABE de destino en sandbox.' -mode: 'wide' ---- - -## Qué hace - -`POST /api/spei/cash-out` solicita el envío de SPEI a una CLABE de destino. En sandbox, el pipeline contable completo se ejercita — el saldo se debita, la tarifa se cobra, se genera registro en el extracto — pero la llamada a Banxico es simulada. - -La respuesta HTTP siempre es `201 Created` con `status: PENDING`. El resultado final llega vía webhook `cash_out` ~1 segundo después (escenario `success`) o según el escenario forzado. - -## Prerrequisito - -Tu cuenta sandbox necesita saldo. Realiza al menos un [cash-in](/es/sandbox/cash-in) antes — el saldo simulado se debita igual que en producción. - -## Ejemplo - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "beneficiaryTaxId": "LOPM850101ABC", - "concept": "Pago de factura" - }' -``` - -### Response (201) - -```json -{ - "id": 12346, - "status": "PENDING", - "destinationClabe": "012180001234567890", - "amountCentavos": 15000, - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T10:00:00.000Z" -} -``` - -## Webhook esperado - -Tras ~1 segundo (escenario `success`): - -```json -{ - "event": "cash_out", - "deliveryId": "...", - "transaction": { - "id": 12346, - "externalId": "payout-001", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "confirmedAt": "2026-03-26T10:00:01.000Z" - }, - "errorCode": null, - "errorMessage": null -} -``` - -## Escenarios de error útiles - -Fuerza comportamientos específicos vía el header `X-Sandbox-Scenario`: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: insufficient_funds" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -| Escenario | Cuándo usar | Webhook | -|---|---|---| -| `insufficient_funds` | Validar UX cuando tu cliente intenta pagar sin saldo | `FAILED` (`errorCode: INSUFFICIENT_FUNDS`) | -| `bad_clabe` | Validar manejo de devolución por CLABE inválida | `RETURNED` (`errorCode: INVALID_CLABE`) | -| `rejected` | Validar rechazo genérico de la red SPEI | `FAILED` | -| `returned` | Validar transferencia devuelta por el banco contraparte | `RETURNED` | -| `pending_long` | Validar UX de "transferencia en progreso" (~30s) | `CONFIRMED` | -| `timeout` / `provider_5xx` | Validar fallas upstream síncronas (`504` / `503`) | — (error síncrono) | - -Mira [Escenarios](/es/sandbox/scenarios) para la lista completa. - -## Validaciones síncronas - -Incluso en sandbox, algunas validaciones ocurren **antes** del retorno `201`: - -| Error síncrono | HTTP | Cuándo | -|---|---|---| -| `400 INVALID_AMOUNT` | `400` | `amountCentavos <= 0` | -| `400 INVALID_CLABE_FORMAT` | `400` | CLABE con formato inválido (no exactamente 18 dígitos) | -| `400 INSUFFICIENT_FUNDS` | `400` | Saldo real por debajo de `amountCentavos + tarifa` (sin usar escenario) | - - - Los escenarios `insufficient_funds`, `bad_clabe`, `rejected` y `returned` afectan al **webhook** — la respuesta síncrona es `201 PENDING`. `timeout` y `provider_5xx` fallan de forma síncrona, igual que las validaciones estructurales (monto/formato de CLABE). - diff --git a/es/sandbox/introduction.mdx b/es/sandbox/introduction.mdx index da76540..edc4f18 100644 --- a/es/sandbox/introduction.mdx +++ b/es/sandbox/introduction.mdx @@ -1,20 +1,24 @@ --- title: 'Sandbox NTX Pay' -description: 'Ambiente de pruebas con alta fidelidad al pipeline de producción de NTX Pay México.' +description: 'Ambiente de pruebas con alta fidelidad al comportamiento de producción.' mode: 'wide' --- ## Qué es -El sandbox de NTX Pay permite que tu integración ejercite **cash-in**, **cash-out**, **refund** y **webhooks** sin mover dinero real. A diferencia de mocks simples, el pipeline contable completo (saldo TigerBeetle, validación de límites, cobro de tarifas, generación de extractos, entrega de webhooks vía outbox) se ejercita intacto. Solo la liquidación en la red SPEI es simulada. +El sandbox de NTX Pay permite que tu integración ejercite **cash-in**, **cash-out**, **devoluciones** y **webhooks** sin mover dinero real. Se ejercita el pipeline completo — saldo, validación de límites, cobro de tarifas, estado de cuenta y entrega de webhooks — solo la liquidación en la red SPEI es simulada. - Toda integración con NTX Pay empieza por el sandbox. Los endpoints, payloads y webhooks descritos en esta documentación son los definitivos — cuando producción se habilite para tu empresa, el mismo código funcionará simplemente cambiando las credenciales. + **La integración no cambia.** Los endpoints, payloads y webhooks son exactamente los descritos en las [guías](/es/guides/get-started) — cuando producción sea habilitada para tu empresa, el mismo código funcionará con solo cambiar las credenciales. Por eso, esta sección documenta únicamente **lo que es diferente en el sandbox**: los [escenarios de prueba](/es/sandbox/scenarios) y los [webhooks simulados](/es/sandbox/webhooks). -## Cómo activar +## Cómo activarlo -Tus credenciales de API son **estructuralmente las mismas** que usarías en producción. La diferencia vive en la cuenta: las cuentas sandbox enrutan las llamadas SPEI al simulador interno de NTX Pay. Para crear una cuenta sandbox, contacta a tu Account Manager o escribe a `contact@ntxpay.com` — el onboarding es instantáneo y el KYC es auto-aprobado. +Tus credenciales de API son **estructuralmente las mismas** que usarías en producción. La diferencia vive en la cuenta: las cuentas sandbox enrutan las llamadas SPEI al simulador interno de NTX Pay. Para crear una cuenta sandbox, pídelo a tu Account Manager o escribe a `contact@ntxpay.com` — el onboarding es instantáneo y el KYC se aprueba automáticamente. + + + Las credenciales de sandbox son **distintas** de las de producción. Las credenciales de producción contra el host de sandbox devuelven `401`. La [autenticación](/es/guides/authentication) en sí es idéntica. + ## Base URL @@ -22,46 +26,37 @@ Tus credenciales de API son **estructuralmente las mismas** que usarías en prod |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | -Todas las rutas documentadas (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/transactions`, `/api/webhooks-config`) están disponibles exactamente en este host. - -## Escenarios de prueba - -Controlas el comportamiento de cada llamada vía el header HTTP `X-Sandbox-Scenario`. Sin el header, el sandbox devuelve **éxito** por defecto. Mira [Escenarios](/es/sandbox/scenarios) para la lista completa de escenarios de error, éxito y atraso soportados. - -## Webhooks - -Registra tu `webhookUrl` en la cuenta sandbox exactamente como lo harías en producción — vía `POST /api/webhooks-config`. Los eventos son entregados por el mismo motor de outbox que usamos en prod, con las mismas firmas, headers (`X-NTXPay-Delivery`) y política de retry. +Todas las rutas documentadas (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/balance`, `/api/webhooks-config`) están disponibles exactamente en este host. ## Diferencias vs Producción | Aspecto | Sandbox | Producción | |---|---|---| -| Base URL | `https://sandbox.mx.ntxpay.com` | Provista en el onboarding | +| Base URL | `https://sandbox.mx.ntxpay.com` | Proporcionada en el onboarding | | Saldo | Simulado | Fondos reales | -| Confirmación SPEI cash-in | Inmediata (~1s) | Real (segundos a minutos) | -| `X-Sandbox-Scenario` | Soportado | Rechazado con `400` | -| Costo | Gratis | Según contrato | +| Liquidación SPEI | Simulada, en segundos | Real (segundos a minutos) | +| Header `X-Sandbox-Scenario` | Soportado | Rechazado con `400` | +| Costo | Gratuito | Según contrato | + +## Flujo de prueba sugerido + +1. **Autentícate** — [obtén el JWT](/es/guides/authentication) con las credenciales sandbox. +2. **Registra tu webhook** — vía [`POST /api/webhooks-config`](/es/guides/webhooks/setup), exactamente como en producción. +3. **Crea un cash-in** — sigue la [guía de cash-in](/es/guides/spei-cash-in); la confirmación llega simulada en segundos. +4. **Envía un cash-out** — con el saldo del paso anterior, sigue la [guía de cash-out](/es/guides/spei-cash-out). +5. **Fuerza errores y devoluciones** — usa los [escenarios](/es/sandbox/scenarios) para ejercitar todos los caminos de tu handler. ## Próximos pasos - - Cómo obtener el JWT en sandbox usando tus credenciales. - - - Lista completa de escenarios disponibles vía `X-Sandbox-Scenario`. - - - Recibir vía SPEI en sandbox. - - - Enviar vía SPEI en sandbox. + + Fuerza éxito, falla, devolución y retraso vía header `X-Sandbox-Scenario`. - - Cómo el sandbox entrega webhooks y cómo probar dedupe. + + Cómo disparar cada evento y validar dedupe, retry y firma. ## Soporte -`contact@ntxpay.com` +`suporte@ntxpay.com` diff --git a/es/sandbox/scenarios.mdx b/es/sandbox/scenarios.mdx index af97168..fe6e0a3 100644 --- a/es/sandbox/scenarios.mdx +++ b/es/sandbox/scenarios.mdx @@ -1,10 +1,10 @@ --- title: 'Escenarios de prueba' -description: 'Fuerza comportamientos específicos vía el header X-Sandbox-Scenario.' +description: 'Fuerza comportamientos específicos vía header X-Sandbox-Scenario.' mode: 'wide' --- -## Cómo usar +## Cómo usarlo Agrega el header `X-Sandbox-Scenario: ` a cualquier llamada de cash-in o cash-out. Sin el header, el sandbox usa el escenario `success` por defecto. @@ -21,7 +21,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ ``` - La mayoría de los escenarios controlan el **webhook asíncrono**: la respuesta HTTP es `201 Created` con `status: PENDING`, y el resultado final (`CONFIRMED`, `FAILED` o `RETURNED`) llega en el webhook. Las excepciones son `timeout` y `provider_5xx`, que fallan en la respuesta HTTP **síncrona**. + La mayoría de los escenarios controla el **webhook asíncrono**: la respuesta HTTP es `201 Created` con `status: PENDING`, y el resultado final llega en el webhook. Las excepciones son `timeout` y `provider_5xx`, que fallan en la respuesta HTTP **síncrona**. ## Escenarios disponibles @@ -30,24 +30,28 @@ Los valores canónicos de escenario son: `success`, `pending_long`, `rejected`, ### Escenarios de resultado asíncrono -Devuelven `201 PENDING` de forma síncrona; el estado final llega vía webhook. +Devuelven `201 PENDING` de forma síncrona; el estado final llega vía webhook en segundos. -| Header Value | Resultado del webhook | Notas | +| Header Value | Webhook resultante | Notas | |---|---|---| -| `success` (default) | `CONFIRMED` en ~1s | También se usa cuando no se envía header | -| `pending_long` | `CONFIRMED` tras ~30s | Prueba settlement lento | -| `rejected` | `FAILED` | La red SPEI rechazó la transferencia | -| `returned` | `RETURNED` | Aceptada y luego devuelta por el banco contraparte | -| `insufficient_funds` | `FAILED` con `errorCode: INSUFFICIENT_FUNDS` | **Solo cash-out** | -| `bad_clabe` | `RETURNED` con `errorCode: INVALID_CLABE` | **Solo cash-out** — aceptada y luego devuelta | +| `success` (default) | `*.settled` — `status: LIQUIDATED` | También se usa cuando no se envía ningún header | +| `pending_long` | `*.settled` — `status: LIQUIDATED` después de ~30s | Prueba la liquidación lenta | +| `rejected` | `*.rejected` — `status: REJECTED` | La red SPEI rechazó la transferencia | +| `returned` | `*.returned` — `status: RETURNED` | Aceptada y luego devuelta por la contraparte | +| `insufficient_funds` | `cash_out.rejected` — `status: REJECTED` | **Solo cash-out** — simula rechazo por falta de fondos | +| `bad_clabe` | `cash_out.returned` — `status: RETURNED` | **Solo cash-out** — aceptada y devuelta por CLABE inválida | + + + Los eventos `*.returned` se entregan en el webhook del tipo **`refund_in`/`refund_out`** (o `all`), no en el `cash_in`/`cash_out`. Para probar los escenarios `returned` y `bad_clabe`, registra también esos webhooks — consulta los [tipos de webhook](/es/guides/webhooks/overview). + ### Escenarios de error síncrono -Fallan en la propia respuesta HTTP — no se envía webhook. +Fallan en la propia respuesta HTTP — no se envía ningún webhook. | Header Value | Respuesta síncrona | |---|---| -| `timeout` | Timeout upstream (`504`) tras ~16s | +| `timeout` | Timeout en el procesamiento (`504`) después de ~16s | | `provider_5xx` | Servicio temporalmente no disponible (`503`) | @@ -56,64 +60,43 @@ Fallan en la propia respuesta HTTP — no se envía webhook. ## Ejemplo: webhook de éxito +Escenario `success` en un cash-out — el webhook `cash_out` recibe: + ```json { - "event": "cash_out", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:00:00.000Z", - "transaction": { - "id": 12345, - "externalId": "test-success-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T09:59:59.000Z", - "confirmedAt": "2026-03-26T10:00:00.000Z", - "counterpart": { - "name": "Maria Lopez", - "taxId": null, - "bank": {} - } - }, - "errorCode": null, - "errorMessage": null, - "metadata": {} + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 15000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260326G7H8I9", + "occurredAt": "2026-03-26T10:00:00.000Z" } ``` -## Ejemplo: webhook de falla +## Ejemplo: webhook de rechazo + +Escenario `insufficient_funds` — el webhook `cash_out` recibe: ```json { - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:01:00.000Z", - "transaction": { - "id": 12346, - "externalId": "test-error-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "FAILED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": null, - "confirmedAt": null - }, - "errorCode": "INSUFFICIENT_FUNDS", - "errorMessage": "Cuenta sin saldo suficiente", - "metadata": {} + "event": "transaction.cash_out.rejected", + "transactionId": "2b4c8d9e-3f56-4a1b-bc29-88a3b4c5d6f7", + "amount": 15000, + "currency": "MXN", + "status": "REJECTED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": null, + "voucher": null, + "occurredAt": "2026-03-26T10:01:00.000Z" } ``` -Notas: - -- En `status: FAILED`, `referenceNumerical` y `confirmedAt` son `null` — la red SPEI nunca confirmó la transacción. -- `errorCode` y `errorMessage` describen el motivo de la falla. +En `status: REJECTED`, los campos de comprobante (`reference`, `voucher`) vienen `null` — la red SPEI nunca confirmó la transacción. El formato completo del payload está en [Visión General de Webhooks](/es/guides/webhooks/overview). ## Restricciones @@ -129,7 +112,7 @@ Notas: ## Buenas prácticas -1. **Prueba todos los escenarios** antes de ir a producción — implementa el manejo de `CONFIRMED`, `PENDING`, `FAILED` y `EXPIRED`. -2. **Valida los campos de error** — usa `errorCode` para decisiones automáticas; reserva `errorMessage` para logs/usuarios. -3. **Prueba con delay** — verifica que tu sistema maneja bien la entrega lenta del webhook. -4. **Idempotencia** — usa `transaction.id` como clave de idempotencia; el mismo webhook puede ser re-entregado. +1. **Prueba todos los escenarios** antes de salir a producción — implementa el manejo de los cuatro status (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`). +2. **Enruta por el campo `event`** — `*.settled`, `*.rejected` y `*.returned` exigen acciones diferentes en tu sistema. +3. **Prueba con retraso** — usa `pending_long` para verificar que tu sistema maneja bien la liquidación lenta. +4. **Idempotencia** — deduplica por el header `x-event-id`; el mismo evento puede reentregarse. diff --git a/es/sandbox/webhooks.mdx b/es/sandbox/webhooks.mdx index 03ffa34..642489c 100644 --- a/es/sandbox/webhooks.mdx +++ b/es/sandbox/webhooks.mdx @@ -1,58 +1,64 @@ --- -title: 'Webhooks' -description: 'Cómo el sandbox entrega webhooks y cómo probar dedupe, retries y firma.' +title: 'Webhooks simulados' +description: 'Cómo disparar cada evento en el sandbox y validar dedupe, retry y firma.' mode: 'wide' --- ## Cómo funciona -El sandbox usa el **mismo motor de outbox** que producción. Eso significa: +El sandbox usa el **mismo motor de entrega** que producción: -- Misma estructura de payload -- Mismos headers (`X-NTXPay-Delivery`, `X-NTXPay-Signature`, etc.) -- Misma política de retry exponencial +- Misma estructura de payload — consulta el [contrato completo](/es/guides/webhooks/overview) +- Mismos headers (`x-event-id`, `X-NTXPay-Signature`) +- Misma política de retry (5 intentos, backoff exponencial, timeout 10s) - Mismo formato de firma HMAC -La única diferencia es la **velocidad**: los webhooks de sandbox son disparados ~1 segundo después de la request (vs. minutos en producción), y puedes forzar atrasos artificiales vía el escenario `delayed:`. +La diferencia es el **origen**: en lugar de esperar la liquidación real en la red SPEI, el simulador resuelve la transacción en segundos — y tú controlas el desenlace vía [escenarios](/es/sandbox/scenarios). -## Registrar URL +La [configuración del webhook](/es/guides/webhooks/setup) es idéntica a la de producción — registra la URL vía `POST /api/webhooks-config` normalmente. + +## Dos formas de disparar un webhook + +### 1. Webhook de prueba (sin transacción) + +La forma más rápida de validar tu endpoint — dispara una entrega firmada sin mover nada: ```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://mi-servidor.com/webhooks/ntxpay", - "events": ["cash_in"] + "eventType": "cash_in", + "status": "LIQUIDATED" }' ``` -### Response (201) +La respuesta te informa al instante si tu endpoint respondió `2xx`, el tiempo de respuesta y la firma enviada. Varía el `status` (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`) para ejercitar cada camino del handler. Detalles de los campos en [Configuración](/es/guides/webhooks/setup#webhook-de-prueba). -```json -{ - "id": "wh_550e8400", - "url": "https://mi-servidor.com/webhooks/ntxpay", - "events": ["cash_in"], - "secret": "whsec_a1b2c3d4...", - "createdAt": "2026-03-26T09:00:00.000Z" -} -``` +### 2. Transacción simulada (flujo completo) + +Crea un [cash-in](/es/guides/spei-cash-in) o [cash-out](/es/guides/spei-cash-out) con el header `X-Sandbox-Scenario` — corre el pipeline entero (saldo, tarifa, estado de cuenta) y el webhook llega en segundos con el desenlace elegido: -Guarda el `secret` devuelto — se usa para verificar la firma HMAC. **Solo se muestra una vez.** +| Para recibir | Usa el escenario | En el webhook tipo | +|---|---|---| +| `*.settled` (`LIQUIDATED`) | `success` (o ningún header) | `cash_in` / `cash_out` | +| `*.settled` con retraso de ~30s | `pending_long` | `cash_in` / `cash_out` | +| `*.rejected` (`REJECTED`) | `rejected` o `insufficient_funds` | `cash_in` / `cash_out` | +| `*.returned` (`RETURNED`) | `returned` o `bad_clabe` | `refund_in` / `refund_out` | -## Eventos disponibles +Consulta el [catálogo completo de escenarios](/es/sandbox/scenarios). -| Evento | Disparado cuando | -|---|---| -| `cash_in` | CLABE desechable recibe una transferencia (simulada) | -| `cash_out` | Envío SPEI se resuelve (confirmado o falló) | -| `refund_in` | Refund de cash-in se procesa | -| `refund_out` | Refund de cash-out se procesa | +## Probar el dedupe -## Verificar la firma +Cada entrega lleva un `x-event-id` único. Para probar tu dedupe: -Cada webhook llega con el header `X-NTXPay-Signature` en el formato `sha256=`: +1. Configura tu handler para devolver `500` en el primer intento. +2. NTX Pay entregará el mismo mensaje de nuevo (con el **mismo** `x-event-id`). +3. Confirma que tu sistema ignora el duplicado y responde `200` en el segundo intento. + +## Probar la firma + +Apunta un webhook de prueba a tu endpoint y valida el `X-NTXPay-Signature` con el `secret` devuelto en la creación: ```python import hmac @@ -67,45 +73,11 @@ def verify(payload_bytes: bytes, signature_header: str, secret: str) -> bool: return hmac.compare_digest(expected, signature_header) ``` -## Probar dedupe - -Cada entrega tiene un `deliveryId` único en el header `X-NTXPay-Delivery` y dentro del payload. Para probar tu dedupe: - -1. Configura tu handler para retornar `500` en el primer intento. -2. NTX Pay entregará el mismo mensaje nuevamente (con el **mismo** `deliveryId`). -3. Confirma que tu sistema ignora la duplicada y responde `200` en el segundo intento. - -## Política de retry - -| Intento | Atraso tras el anterior | -|---|---| -| 1 | inmediato | -| 2 | 30s | -| 3 | 2min | -| 4 | 10min | -| 5 | 1h | -| 6 | 6h | -| 7+ | abandonado | - -Tu endpoint necesita responder `2xx` en hasta **5 segundos** — cualquier `5xx`, timeout o error de conexión dispara retry. - -## Escenarios de prueba - -Fuerza que el webhook salga como `FAILED` o con delay: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: pending_long" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -Mira [Escenarios](/es/sandbox/scenarios) para el catálogo completo. +Handlers completos en Node.js, Python, Java y Go: [Implementación](/es/guides/webhooks/implementation). ## Buenas prácticas -1. **Responde 200 antes de procesar** — encola el evento en background; cinco segundos es el tope. -2. **Usa `deliveryId` para dedupe** — no confíes en `transaction.id` (los retries llegan con el mismo `transaction.id` pero `deliveryId` nuevo en caso de redrive manual). -3. **No dependas del orden** — los webhooks pueden llegar fuera de orden tras retries. -4. **Valida siempre la firma** — incluso en sandbox. +1. **Valida la firma** — siempre, incluso en sandbox. +2. **Usa `x-event-id` para el dedupe** — el mismo evento puede reentregarse. +3. **No dependas del orden** — los webhooks pueden llegar fuera de orden después de retries. +4. **Ejercita los cuatro status** antes de ir a producción — el sandbox existe para eso. diff --git a/pt-br/endpoints/webhooks-config-test.mdx b/pt-br/endpoints/webhooks-config-test.mdx new file mode 100644 index 0000000..930b541 --- /dev/null +++ b/pt-br/endpoints/webhooks-config-test.mdx @@ -0,0 +1,3 @@ +--- +openapi: post /api/webhooks-config/test +--- diff --git a/pt-br/guides/authentication.mdx b/pt-br/guides/authentication.mdx index 4b1d347..5b25b68 100644 --- a/pt-br/guides/authentication.mdx +++ b/pt-br/guides/authentication.mdx @@ -13,6 +13,10 @@ A API NTX Pay México usa autenticação em duas camadas: A combinação retorna um **JWT** (validade 10 minutos) usado nos demais endpoints como `Authorization: Bearer ...`. + + A autenticação no **sandbox é idêntica** — o que muda é o par certificado + `clientId`/`clientSecret`, que é distinto do de produção. Credenciais de produção contra `https://sandbox.mx.ntxpay.com` retornam `401`. + + ## Endpoint ### POST /api/auth/token @@ -64,7 +68,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ Inclua o `access_token` em todas as requisições autenticadas: ```bash -curl -X GET https://sandbox.mx.ntxpay.com/api/spei/transaction/order-abc-123 \ +curl -X GET https://sandbox.mx.ntxpay.com/api/balance \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ``` @@ -240,10 +244,10 @@ func getToken() (string, error) { ## Próximos Passos - - Use o header `X-Sandbox-Scenario` para simular sucesso, falha e atraso nos webhooks + + Aplique o token Bearer e consulte o saldo da conta - - Aplique o token Bearer e faça sua primeira cobrança SPEI Cash-In + + Faça sua primeira cobrança SPEI diff --git a/pt-br/guides/balance.mdx b/pt-br/guides/balance.mdx index dac98cb..32c676d 100644 --- a/pt-br/guides/balance.mdx +++ b/pt-br/guides/balance.mdx @@ -101,7 +101,7 @@ async function safeSpeiCashOut(amountCentavos: number, token: string, dto: any) |---|---| | `200` | Saldo consultado | | `401` | Token inválido ou ausente | -| `502` | `account-ms` indisponível | +| `502` | Serviço temporariamente indisponível — tente novamente | ## Próximos Passos diff --git a/pt-br/guides/get-started.mdx b/pt-br/guides/get-started.mdx index 72a4279..6f27571 100644 --- a/pt-br/guides/get-started.mdx +++ b/pt-br/guides/get-started.mdx @@ -54,6 +54,23 @@ A API NTX Pay México permite que sua empresa realize operações de pagamento, |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | +## Trilha de Integração + + + + Certificado + OAuth 2.0 para obter o JWT + + + Receba pagamentos via CLABE descartável + + + Receba notificações de cada evento + + + Cenários de teste e webhooks simulados + + + ## Suporte -`support@ntxpay.com` +`suporte@ntxpay.com` diff --git a/pt-br/guides/postman-collections.mdx b/pt-br/guides/postman-collections.mdx index 83f65ce..863798b 100644 --- a/pt-br/guides/postman-collections.mdx +++ b/pt-br/guides/postman-collections.mdx @@ -6,7 +6,7 @@ mode: 'wide' ## Como obter a coleção -A coleção oficial do Postman com todos os endpoints públicos da NTX Pay México é distribuída sob demanda. Solicite o arquivo `.json` (Postman v2.1) por e-mail para `support@ntxpay.com`. +A coleção oficial do Postman com todos os endpoints públicos da NTX Pay México é distribuída sob demanda. Solicite o arquivo `.json` (Postman v2.1) por e-mail para `suporte@ntxpay.com`. ## Importar no Postman @@ -31,14 +31,14 @@ A coleção está organizada na ordem típica de uso: 1. **Auth → Generate Token** — execute primeiro. O response salva automaticamente o `access_token` em uma variável de environment. 2. **SPEI → Cash-In** — cria uma cobrança simulada para confirmar que o token está funcionando. -3. **Transactions → List** — consulta o estado da cobrança recém-criada. +3. **Webhooks Config → Test** — dispara um webhook de teste no seu endpoint. ## Pastas Disponíveis - `Auth` — geração de JWT -- `SPEI` — cash-in, cash-out, get transaction by externalId -- `Transactions` — listar -- `Webhooks Config` — listar, criar, deletar +- `SPEI` — cash-in, cash-out +- `Balance` — consulta de saldo +- `Webhooks Config` — listar, criar, testar, deletar ## Alternativas @@ -50,4 +50,4 @@ Prefere outra ferramenta? ## Suporte -`support@ntxpay.com` +`suporte@ntxpay.com` diff --git a/pt-br/guides/spei-cash-in.mdx b/pt-br/guides/spei-cash-in.mdx index 7289564..c365fce 100644 --- a/pt-br/guides/spei-cash-in.mdx +++ b/pt-br/guides/spei-cash-in.mdx @@ -5,7 +5,7 @@ description: 'Receba pagamentos SPEI via CLABE descartável de uso único' ## Visão Geral -O **cash-in SPEI** gera uma **CLABE descartável** que o pagador usa para fazer uma transferência SPEI pelo app do banco. Quando a NTX Pay recebe a liquidação, a transação passa para `CONFIRMED` e dispara o webhook `cash_in`. +O **cash-in SPEI** gera uma **CLABE descartável** que o pagador usa para fazer uma transferência SPEI pelo app do banco. Quando a NTX Pay recebe a liquidação, você é notificado no webhook `cash_in` com o evento `transaction.cash_in.settled`. Características: @@ -58,6 +58,8 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ } ``` +Exiba a `destinationClabe` (e/ou a `checkoutUrl`) ao pagador final. + ## Campos do Request @@ -98,7 +100,7 @@ sequenceDiagram App->>Payer: Exibe CLABE (e/ou checkoutUrl) Payer->>Bank: Transferência SPEI para destinationClabe Bank-->>NTX: Liquidação SPEI - NTX->>App: webhook cash_in (CONFIRMED) + NTX->>App: webhook cash_in (transaction.cash_in.settled) ``` ## Estados da Transação @@ -110,15 +112,30 @@ sequenceDiagram | `FAILED` | Erro de processamento | | `EXPIRED` | CLABE expirou sem receber transferência | +No webhook, a liquidação chega como `transaction.cash_in.settled` com `status: LIQUIDATED` — veja o [payload completo](/pt-br/guides/webhooks/cash-in). + ## Idempotência Reenvie a mesma requisição com o mesmo `externalId` para garantir que uma falha de rede não gere duas cobranças. Em caso de duplicação, a NTX Pay retorna a cobrança existente. +## Testar no Sandbox + +No sandbox, a liquidação é simulada em segundos — sem depender de um banco emissor. Controle o desfecho com o header `X-Sandbox-Scenario`: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Sandbox-Scenario: rejected" \ + ... +``` + +Veja o [catálogo de cenários](/pt-br/sandbox/scenarios) para forçar rejeição, devolução e atraso. + ## Próximos Passos - Detalhes do payload do webhook de confirmação + Payload do webhook de confirmação Envie transferências SPEI diff --git a/pt-br/guides/spei-cash-out.mdx b/pt-br/guides/spei-cash-out.mdx index 6ac3300..437e381 100644 --- a/pt-br/guides/spei-cash-out.mdx +++ b/pt-br/guides/spei-cash-out.mdx @@ -5,7 +5,7 @@ description: 'Envie transferências SPEI para qualquer CLABE' ## Visão Geral -O **cash-out SPEI** envia uma transferência interbancária para uma **CLABE de destino**. O saldo da conta é debitado e a NTX Pay processa a transferência na rede SPEI. A confirmação chega via webhook `cash_out`. +O **cash-out SPEI** envia uma transferência interbancária para uma **CLABE de destino**. O saldo da conta é debitado e a NTX Pay processa a transferência na rede SPEI. A confirmação chega no webhook `cash_out` com o evento `transaction.cash_out.settled`. ## Endpoint @@ -88,8 +88,12 @@ if (balance.availableCentavos < amountCentavos) { | Status | Significado | |---|---| | `PENDING` | Cash-out aceito, aguardando liquidação SPEI | -| `CONFIRMED` | Liquidado no Banxico | -| `FAILED` | Rejeitado pela rede SPEI | +| `CONFIRMED` | Liquidado na rede SPEI | +| `FAILED` | Rejeitado pela rede SPEI — o saldo debitado é devolvido | + +No webhook, os desfechos chegam como `transaction.cash_out.settled` (`LIQUIDATED`) e `transaction.cash_out.rejected` (`REJECTED`) — veja o [payload completo](/pt-br/guides/webhooks/cash-out). + +**Devolução após a liquidação:** se o banco da contraparte devolver a transferência, o saldo é creditado de volta e você recebe `transaction.cash_out.returned` no webhook [`refund_out`](/pt-br/guides/webhooks/refund-out). ## Códigos de Erro @@ -97,7 +101,7 @@ if (balance.availableCentavos < amountCentavos) { |---|---| | `400` | Saldo insuficiente, CLABE inválida, payload inválido | | `401` | Token inválido | -| `502` | Falha temporária no processamento — não tente novamente sem verificar o status via `GET /api/transactions` | +| `502` | Falha temporária no processamento — não tente novamente sem confirmar o desfecho da transação original (aguarde o webhook) | ## Exemplo em Node.js com Retry @@ -112,18 +116,25 @@ async function speiCashOut(token: string, dto: any) { return data; // status: PENDING } catch (err) { if (err.response?.status === 502) { - // Não sabemos se a transação foi criada. Consulte /api/transactions filtrando por externalId - // antes de tentar novamente. + // Não sabemos se a transação foi criada. Aguarde o webhook (ou contate o + // suporte) antes de tentar novamente — retry cego pode duplicar o envio. } throw err; } } ``` +## Testar no Sandbox + +No sandbox, o pipeline completo roda — saldo debitado, tarifa cobrada, extrato gerado — e a liquidação é simulada em segundos. Sua conta precisa de saldo: faça um [cash-in](/pt-br/guides/spei-cash-in) antes. Force rejeição, devolução e falhas síncronas com o header `X-Sandbox-Scenario` — veja o [catálogo de cenários](/pt-br/sandbox/scenarios). + ## Próximos Passos - Detalhes do payload do webhook de liquidação + Payload do webhook de liquidação + + + Como chegam as devoluções de cash-out diff --git a/pt-br/guides/webhooks/cash-in.mdx b/pt-br/guides/webhooks/cash-in.mdx index 4cb45be..b1d424d 100644 --- a/pt-br/guides/webhooks/cash-in.mdx +++ b/pt-br/guides/webhooks/cash-in.mdx @@ -1,49 +1,55 @@ --- -title: 'Evento cash_in' -description: 'Notificação enviada quando um SPEI cash-in é confirmado' +title: 'Webhook cash_in' +description: 'Notificações do ciclo de vida de um SPEI cash-in' mode: 'wide' --- ## Quando dispara -O evento `cash_in` é disparado quando: +O webhook do tipo `cash_in` recebe os eventos do ciclo de vida de uma cobrança criada via `POST /api/spei/cash-in`: -- Uma transferência SPEI chega na **CLABE descartável** emitida por `POST /api/spei/cash-in` e é liquidada +| `event` | `status` | Significado | +|---|---|---| +| `transaction.cash_in.settled` | `LIQUIDATED` | A transferência SPEI chegou na CLABE descartável e foi liquidada | +| `transaction.cash_in.rejected` | `REJECTED` | A rede SPEI rejeitou a transferência | +| `transaction.cash_in.pending` | `PENDING` | Atualização intermediária de processamento | + + + A **devolução** de um cash-in já liquidado (`transaction.cash_in.returned`) é entregue no webhook do tipo [`refund_in`](/pt-br/guides/webhooks/refund-in), não no `cash_in`. + ## Payload ```json { - "event": "cash_in", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-05-12T14:31:05.000Z", - "transaction": { - "id": 12345, - "externalId": "order-abc-123", - "paymentMethod": "SPEI", - "direction": "in", - "type": "cash_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-12T14:30:00.000Z", - "confirmedAt": "2026-05-12T14:31:05.000Z" - } + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" } ``` +- `destinationClabe` — a CLABE descartável emitida na criação da cobrança +- `sourceClabe` — a CLABE do pagador (quando informada pela rede) +- `amount` — valor em centavos MXN +- Campos de referência (`reference`, `voucher`) podem vir `null` dependendo do fluxo + ## Headers | Header | Valor | |---|---| -| `X-NTXPay-Event` | `cash_in` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch (segundos) | -| `X-NTXPay-Delivery` | UUID único do envio | +| `x-event-id` | UUID único do evento (use para dedupe) | +| `X-NTXPay-Signature` | `sha256=` do corpo bruto | ## Resposta Esperada -Responda `200 OK` em menos de 10 segundos. Em caso de qualquer status diferente, o NTX Pay tenta novamente até 5 vezes em backoff exponencial. +Responda `200 OK` em menos de 10 segundos. Em caso de qualquer status diferente de `2xx`, o NTX Pay tenta novamente até 5 vezes em backoff exponencial. ```http HTTP/1.1 200 OK @@ -52,163 +58,15 @@ Content-Type: application/json {"received": true} ``` -## Exemplos de Handler - - - -```typescript Node.js -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body for HMAC - -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; - -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) // req.body is Buffer - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'cash_in' && event.transaction.status === 'CONFIRMED') { - enqueue(event); // process async - } - - res.json({ received: true }); -}); -``` - -```python Python -import hmac -import hashlib -import json -import os -from flask import Flask, request, abort, jsonify - -app = Flask(__name__) -SECRET = os.environ["NTXPAY_WEBHOOK_SECRET"].encode() - -@app.post("/webhooks/ntxpay") -def webhook(): - raw = request.get_data() # raw bytes — required for HMAC - sig = request.headers.get("X-NTXPay-Signature", "") - expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() - - if not hmac.compare_digest(sig, expected): - abort(401) - - event = json.loads(raw) - if event["event"] == "cash_in" and event["transaction"]["status"] == "CONFIRMED": - enqueue(event) # process async - - return jsonify(received=True) -``` - -```java Java -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.MessageDigest; -import java.util.Map; - -@RestController -public class NtxPayCashInHandler { - private static final byte[] SECRET = - System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); - private final ObjectMapper mapper = new ObjectMapper(); - - @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity handle( - @RequestHeader("X-NTXPay-Signature") String sig, - @RequestBody byte[] raw - ) throws Exception { - String expected = "sha256=" + hmacSha256Hex(SECRET, raw); - if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { - return ResponseEntity.status(401).build(); - } - - Map event = mapper.readValue(raw, new TypeReference<>() {}); - Map tx = (Map) event.get("transaction"); - if ("cash_in".equals(event.get("event")) - && "CONFIRMED".equals(tx.get("status"))) { - enqueue(event); // process async - } - - return ResponseEntity.ok(Map.of("received", true)); - } - - private static String hmacSha256Hex(byte[] secret, byte[] data) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - byte[] result = mac.doFinal(data); - StringBuilder sb = new StringBuilder(result.length * 2); - for (byte b : result) sb.append(String.format("%02x", b)); - return sb.toString(); - } -} -``` - -```go Go -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "os" -) - -var secret = []byte(os.Getenv("NTXPAY_WEBHOOK_SECRET")) - -type webhookEvent struct { - Event string `json:"event"` - Transaction struct { - Status string `json:"status"` - } `json:"transaction"` -} - -func handleCashIn(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - - sig := r.Header.Get("X-NTXPay-Signature") - h := hmac.New(sha256.New, secret) - h.Write(raw) - expected := "sha256=" + hex.EncodeToString(h.Sum(nil)) +## Processamento - if !hmac.Equal([]byte(sig), []byte(expected)) { - w.WriteHeader(http.StatusUnauthorized) - return - } - - var event webhookEvent - if err := json.Unmarshal(raw, &event); err == nil { - if event.Event == "cash_in" && event.Transaction.Status == "CONFIRMED" { - enqueue(raw) // process async - } - } - - _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) -} +Marque o pedido como pago somente quando `event` for `transaction.cash_in.settled` (ou `status: LIQUIDATED`): -func main() { - http.HandleFunc("/webhooks/ntxpay", handleCashIn) - _ = http.ListenAndServe(":8080", nil) +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.settled') { + await markOrderPaid(event.transactionId, event.amount); } ``` - \ No newline at end of file +Para o handler completo com validação HMAC e dedupe em Node.js, Python, Java e Go, veja [Implementação](/pt-br/guides/webhooks/implementation). diff --git a/pt-br/guides/webhooks/cash-out.mdx b/pt-br/guides/webhooks/cash-out.mdx index 129c3fc..66a4257 100644 --- a/pt-br/guides/webhooks/cash-out.mdx +++ b/pt-br/guides/webhooks/cash-out.mdx @@ -1,258 +1,68 @@ --- -title: 'Evento cash_out' -description: 'Notificação enviada quando um SPEI cash-out é liquidado ou falha' +title: 'Webhook cash_out' +description: 'Notificações do ciclo de vida de um SPEI cash-out' mode: 'wide' --- ## Quando dispara -O evento `cash_out` é disparado em dois cenários: +O webhook do tipo `cash_out` recebe os eventos do ciclo de vida de uma transferência criada via `POST /api/spei/cash-out`: -- **Sucesso** — o SPEI cash-out enviado via `POST /api/spei/cash-out` foi liquidado no Banxico (`status: CONFIRMED`) -- **Falha** — a rede SPEI rejeitou a transferência (`status: FAILED`) +| `event` | `status` | Significado | +|---|---|---| +| `transaction.cash_out.settled` | `LIQUIDATED` | A transferência foi liquidada na rede SPEI | +| `transaction.cash_out.rejected` | `REJECTED` | A rede SPEI rejeitou a transferência — o saldo debitado é devolvido | +| `transaction.cash_out.pending` | `PENDING` | Atualização intermediária de processamento | -## Payload (confirmado) + + A **devolução** de um cash-out já liquidado (`transaction.cash_out.returned`) — quando o banco da contraparte devolve a transferência — é entregue no webhook do tipo [`refund_out`](/pt-br/guides/webhooks/refund-out), não no `cash_out`. + -```json -{ - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-05-13T12:00:42.000Z", - "transaction": { - "id": 56789, - "externalId": "payout-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": "2026-05-13T12:00:42.000Z" - } -} -``` - -## Payload (falha) +## Payload ```json { - "event": "cash_out", - "deliveryId": "...", - "createdAt": "2026-05-13T12:01:00.000Z", - "transaction": { - "id": 56789, - "status": "FAILED", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-13T12:00:00.000Z", - "confirmedAt": null - } + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260513D4E5F6", + "occurredAt": "2026-05-13T12:00:42.000Z" } ``` -Quando `status: FAILED`, o saldo bloqueado é devolvido automaticamente. +Em `status: REJECTED`, os campos de comprovante (`reference`, `voucher`) podem vir `null` — a rede SPEI nunca confirmou a transação. ## Headers | Header | Valor | |---|---| -| `X-NTXPay-Event` | `cash_out` | -| `X-NTXPay-Signature` | `sha256=` | -| `X-NTXPay-Timestamp` | Unix epoch | -| `X-NTXPay-Delivery` | UUID | +| `x-event-id` | UUID único do evento (use para dedupe) | +| `X-NTXPay-Signature` | `sha256=` do corpo bruto | ## Comportamento -- **At-least-once**: você pode receber `CONFIRMED` mais de uma vez. Deduplique por `transaction.id`. -- **Falha após sucesso**: não acontece. Uma transação não muda de `CONFIRMED` para `FAILED`. -- **Reversão**: se a contraparte (beneficiário) devolver, você recebe um evento `refund_in` separado, com `transaction.type = "refund_in"` linkado pelo `externalId`. +- **At-least-once**: você pode receber o mesmo evento mais de uma vez. Deduplique por `x-event-id`. +- **Falha após sucesso**: não acontece. Uma transação não muda de `LIQUIDATED` para `REJECTED`. +- **Devolução**: se a contraparte devolver após a liquidação, você recebe `transaction.cash_out.returned` no webhook `refund_out`, com o mesmo `transactionId`. ## Resposta Esperada -```http -HTTP/1.1 200 OK -``` - -## Exemplos de Handler - - - -```typescript Node.js -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body for HMAC - -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; +`200 OK` em até 10 segundos. -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) // req.body is Buffer - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'cash_out') { - if (event.transaction.status === 'CONFIRMED') { - markPayoutSettled(event.transaction.id); - } else if (event.transaction.status === 'FAILED') { - markPayoutFailed(event.transaction.id); - } - } - - res.json({ received: true }); -}); -``` - -```python Python -import hmac -import hashlib -import json -import os -from flask import Flask, request, abort, jsonify - -app = Flask(__name__) -SECRET = os.environ["NTXPAY_WEBHOOK_SECRET"].encode() - -@app.post("/webhooks/ntxpay") -def webhook(): - raw = request.get_data() # raw bytes — required for HMAC - sig = request.headers.get("X-NTXPay-Signature", "") - expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() - - if not hmac.compare_digest(sig, expected): - abort(401) - - event = json.loads(raw) - if event["event"] == "cash_out": - status = event["transaction"]["status"] - tx_id = event["transaction"]["id"] - if status == "CONFIRMED": - mark_payout_settled(tx_id) - elif status == "FAILED": - mark_payout_failed(tx_id) - - return jsonify(received=True) -``` +## Processamento -```java Java -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.MessageDigest; -import java.util.Map; - -@RestController -public class NtxPayCashOutHandler { - private static final byte[] SECRET = - System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); - private final ObjectMapper mapper = new ObjectMapper(); - - @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity handle( - @RequestHeader("X-NTXPay-Signature") String sig, - @RequestBody byte[] raw - ) throws Exception { - String expected = "sha256=" + hmacSha256Hex(SECRET, raw); - if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { - return ResponseEntity.status(401).build(); - } - - Map event = mapper.readValue(raw, new TypeReference<>() {}); - Map tx = (Map) event.get("transaction"); - if ("cash_out".equals(event.get("event"))) { - long txId = ((Number) tx.get("id")).longValue(); - String status = (String) tx.get("status"); - if ("CONFIRMED".equals(status)) { - markPayoutSettled(txId); - } else if ("FAILED".equals(status)) { - markPayoutFailed(txId); - } - } - - return ResponseEntity.ok(Map.of("received", true)); - } - - private static String hmacSha256Hex(byte[] secret, byte[] data) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - byte[] result = mac.doFinal(data); - StringBuilder sb = new StringBuilder(result.length * 2); - for (byte b : result) sb.append(String.format("%02x", b)); - return sb.toString(); - } +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.settled') { + await markPayoutSettled(event.transactionId); +} else if (event.event === 'transaction.cash_out.rejected') { + await markPayoutFailed(event.transactionId); // saldo devolvido automaticamente } ``` -```go Go -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "os" -) - -var secret = []byte(os.Getenv("NTXPAY_WEBHOOK_SECRET")) - -type cashOutEvent struct { - Event string `json:"event"` - Transaction struct { - ID int64 `json:"id"` - Status string `json:"status"` - } `json:"transaction"` -} - -func handleCashOut(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - - sig := r.Header.Get("X-NTXPay-Signature") - h := hmac.New(sha256.New, secret) - h.Write(raw) - expected := "sha256=" + hex.EncodeToString(h.Sum(nil)) - - if !hmac.Equal([]byte(sig), []byte(expected)) { - w.WriteHeader(http.StatusUnauthorized) - return - } - - var event cashOutEvent - if err := json.Unmarshal(raw, &event); err == nil && event.Event == "cash_out" { - switch event.Transaction.Status { - case "CONFIRMED": - markPayoutSettled(event.Transaction.ID) - case "FAILED": - markPayoutFailed(event.Transaction.ID) - } - } - - _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) -} - -func main() { - http.HandleFunc("/webhooks/ntxpay", handleCashOut) - _ = http.ListenAndServe(":8080", nil) -} -``` - - - +Para o handler completo com validação HMAC e dedupe em Node.js, Python, Java e Go, veja [Implementação](/pt-br/guides/webhooks/implementation). diff --git a/pt-br/guides/webhooks/implementation.mdx b/pt-br/guides/webhooks/implementation.mdx index 792cb66..e1d1d57 100644 --- a/pt-br/guides/webhooks/implementation.mdx +++ b/pt-br/guides/webhooks/implementation.mdx @@ -10,7 +10,7 @@ Toda implementação de webhook precisa cobrir 3 coisas: 1. **Validação HMAC** com o `secret` recebido na criação do webhook 2. **Resposta rápida** (`200 OK` em ≤10s) -3. **Idempotência** via `X-NTXPay-Delivery` ou `transaction.id` +3. **Idempotência** via header `x-event-id` ## Exemplos de Código @@ -30,7 +30,7 @@ const seen = new Set(); // production: Redis with TTL app.post('/webhooks/ntxpay', async (req, res) => { const sig = req.header('X-NTXPay-Signature') ?? ''; - const deliveryId = req.header('X-NTXPay-Delivery') ?? ''; + const eventId = req.header('x-event-id') ?? ''; const expected = 'sha256=' + crypto .createHmac('sha256', SECRET) @@ -43,8 +43,8 @@ app.post('/webhooks/ntxpay', async (req, res) => { } // Dedupe - if (seen.has(deliveryId)) return res.json({ duplicate: true }); - seen.add(deliveryId); + if (seen.has(eventId)) return res.json({ duplicate: true }); + seen.add(eventId); const event = JSON.parse(req.body.toString()); @@ -68,15 +68,15 @@ seen = set() # production: Redis with TTL def webhook(): raw = request.get_data() # raw bytes — essential so HMAC matches sig = request.headers.get('X-NTXPay-Signature', '') - delivery_id = request.headers.get('X-NTXPay-Delivery', '') + event_id = request.headers.get('x-event-id', '') expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): abort(401) - if delivery_id in seen: + if event_id in seen: return jsonify(duplicate=True) - seen.add(delivery_id) + seen.add(event_id) event = request.get_json() # enqueue asynchronously @@ -107,14 +107,14 @@ public class NtxPayWebhook { @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity handle( @RequestHeader("X-NTXPay-Signature") String sig, - @RequestHeader("X-NTXPay-Delivery") String deliveryId, + @RequestHeader("x-event-id") String eventId, @RequestBody byte[] raw // raw bytes — essential so HMAC matches ) throws Exception { String expected = "sha256=" + hmacSha256Hex(SECRET, raw); if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { return ResponseEntity.status(401).build(); } - if (!seen.add(deliveryId)) { + if (!seen.add(eventId)) { return ResponseEntity.ok(Map.of("duplicate", true)); } @@ -161,7 +161,7 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { } sig := r.Header.Get("X-NTXPay-Signature") - deliveryID := r.Header.Get("X-NTXPay-Delivery") + eventID := r.Header.Get("x-event-id") h := hmac.New(sha256.New, secret) h.Write(raw) @@ -173,12 +173,12 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) { } seenMu.Lock() - if seen[deliveryID] { + if seen[eventID] { seenMu.Unlock() _ = json.NewEncoder(w).Encode(map[string]bool{"duplicate": true}) return } - seen[deliveryID] = true + seen[eventID] = true seenMu.Unlock() // enqueue async processing @@ -197,9 +197,9 @@ func main() { O HMAC é calculado sobre os **bytes exatos** que o NTX Pay enviou. Se o framework parsear o JSON antes (rearranjando espaços, reordenando campos), a assinatura não bate. Sempre capture o body bruto em **bytes** antes de fazer parse. -## Eventos por Status +## Roteando por Evento e Status -Filtre antes de processar: +O campo `event` identifica o fluxo (`transaction.cash_in.*` / `transaction.cash_out.*`) e o `status` o resultado. Filtre antes de processar: @@ -207,22 +207,20 @@ Filtre antes de processar: const event = JSON.parse(req.body.toString()); switch (event.event) { - case 'cash_in': - if (event.transaction.status === 'CONFIRMED') { - await markOrderPaid(event.transaction.externalId); - } + case 'transaction.cash_in.settled': + await markOrderPaid(event.transactionId, event.amount); break; - case 'cash_out': - if (event.transaction.status === 'CONFIRMED') { - await markPayoutSettled(event.transaction.id); - } else if (event.transaction.status === 'FAILED') { - await markPayoutFailed(event.transaction.id); - } + case 'transaction.cash_out.settled': + await markPayoutSettled(event.transactionId); + break; + + case 'transaction.cash_out.rejected': + await markPayoutFailed(event.transactionId); break; - case 'refund_in': - case 'refund_out': + case 'transaction.cash_in.returned': + case 'transaction.cash_out.returned': await processRefund(event); break; } @@ -230,74 +228,53 @@ switch (event.event) { ```python Python event = request.get_json() -evt_type = event["event"] -tx = event["transaction"] -status = tx["status"] - -if evt_type == "cash_in": - if status == "CONFIRMED": - mark_order_paid(tx["externalId"]) - -elif evt_type == "cash_out": - if status == "CONFIRMED": - mark_payout_settled(tx["id"]) - elif status == "FAILED": - mark_payout_failed(tx["id"]) - -elif evt_type in ("refund_in", "refund_out"): - process_refund(event) + +match event["event"]: + case "transaction.cash_in.settled": + mark_order_paid(event["transactionId"], event["amount"]) + case "transaction.cash_out.settled": + mark_payout_settled(event["transactionId"]) + case "transaction.cash_out.rejected": + mark_payout_failed(event["transactionId"]) + case "transaction.cash_in.returned" | "transaction.cash_out.returned": + process_refund(event) ``` ```java Java // `raw` is the request body byte[], `mapper` is a Jackson ObjectMapper Map event = mapper.readValue(raw, new TypeReference<>() {}); String evtType = (String) event.get("event"); -Map tx = (Map) event.get("transaction"); -String status = (String) tx.get("status"); +String txId = (String) event.get("transactionId"); switch (evtType) { - case "cash_in" -> { - if ("CONFIRMED".equals(status)) { - markOrderPaid((String) tx.get("externalId")); - } - } - case "cash_out" -> { - if ("CONFIRMED".equals(status)) { - markPayoutSettled(((Number) tx.get("id")).longValue()); - } else if ("FAILED".equals(status)) { - markPayoutFailed(((Number) tx.get("id")).longValue()); - } - } - case "refund_in", "refund_out" -> processRefund(event); + case "transaction.cash_in.settled" -> + markOrderPaid(txId, ((Number) event.get("amount")).longValue()); + case "transaction.cash_out.settled" -> markPayoutSettled(txId); + case "transaction.cash_out.rejected" -> markPayoutFailed(txId); + case "transaction.cash_in.returned", "transaction.cash_out.returned" -> + processRefund(event); } ``` ```go Go var event struct { - Event string `json:"event"` - Transaction struct { - ID int64 `json:"id"` - ExternalID string `json:"externalId"` - Status string `json:"status"` - } `json:"transaction"` + Event string `json:"event"` + TransactionID string `json:"transactionId"` + Amount int64 `json:"amount"` + Status string `json:"status"` } if err := json.Unmarshal(raw, &event); err != nil { return err } switch event.Event { -case "cash_in": - if event.Transaction.Status == "CONFIRMED" { - markOrderPaid(event.Transaction.ExternalID) - } -case "cash_out": - switch event.Transaction.Status { - case "CONFIRMED": - markPayoutSettled(event.Transaction.ID) - case "FAILED": - markPayoutFailed(event.Transaction.ID) - } -case "refund_in", "refund_out": +case "transaction.cash_in.settled": + markOrderPaid(event.TransactionID, event.Amount) +case "transaction.cash_out.settled": + markPayoutSettled(event.TransactionID) +case "transaction.cash_out.rejected": + markPayoutFailed(event.TransactionID) +case "transaction.cash_in.returned", "transaction.cash_out.returned": processRefund(event) } ``` @@ -306,7 +283,7 @@ case "refund_in", "refund_out": ## Re-Tentativas -Se você devolver status ≠ `2xx`, o NTX Pay retenta até **5 vezes** em backoff exponencial (~30s, 1m, 5m, 15m, 1h). Depois disso, o evento é descartado. Para reenviar manualmente, use o painel ou contate suporte. +Se você devolver status ≠ `2xx` (ou estourar o timeout de 10s), o NTX Pay retenta até **5 vezes** em backoff exponencial a partir de ~5 segundos. Depois disso, a entrega é marcada como falha — o reenvio manual pode ser solicitado ao suporte. Não use `429` para sinalizar rate-limit do seu próprio serviço — isso aciona retry e amplifica a carga. Responda `503 Service Unavailable` se realmente não puder processar. @@ -317,5 +294,4 @@ Se você devolver status ≠ `2xx`, o NTX Pay retenta até **5 vezes** em backof - **Use Redis/banco para dedupe** com TTL ≥ 24h (não memória in-process) - **Processe assíncrono**: webhook handler só valida + enfileira - **Monitore latência** do handler — alvo P95 < 500ms -- **Logue `X-NTXPay-Delivery`** para auditoria -- **Re-consulte `GET /api/spei/transaction/{externalId}`** se o webhook trouxer estado conflitante com seu banco +- **Logue `x-event-id`** para auditoria diff --git a/pt-br/guides/webhooks/overview.mdx b/pt-br/guides/webhooks/overview.mdx index 46ed92b..53f0a81 100644 --- a/pt-br/guides/webhooks/overview.mdx +++ b/pt-br/guides/webhooks/overview.mdx @@ -6,61 +6,101 @@ mode: 'wide' ## O que são Webhooks -Webhooks permitem que o NTX Pay envie notificações HTTPS para o seu servidor sempre que um evento relevante ocorre — confirmação de cash-in, falha de cash-out, etc. — sem você precisar fazer polling em `GET /api/spei/transaction/{externalId}`. +Webhooks permitem que o NTX Pay envie notificações HTTPS para o seu servidor sempre que um evento relevante ocorre — confirmação de cash-in, falha de cash-out, devolução — sem você precisar fazer polling. -## Eventos Disponíveis (verificar se tem mais eventos a serem adicionados) +## Tipos de Webhook -| Evento | Quando dispara | -|---|---| -| `cash_in` | SPEI cash-in confirmado | -| `cash_out` | SPEI cash-out liquidado | -| `refund_in` | Estorno **recebido** (uma transação cash-out sua foi devolvida) | -| `refund_out` | Estorno **enviado** (você devolveu um cash-in) | -| `internal_transfer` | Transferência interna entre contas NTX Pay | - -## Configuração - -Endpoint: `POST /api/webhooks-config`. Você fornece: - -- **`url`** — endpoint HTTPS no seu servidor -- **`events`** — array com exatamente 1 evento (um webhook assina um evento) -- **`secret`** — opcional. Se omitido, o NTX Pay gera um automaticamente e retorna na resposta. +Ao registrar um webhook via `POST /api/webhooks-config`, você escolhe **qual tipo de evento** aquela URL recebe: -Veja o [guia de Setup](/pt-br/guides/webhooks/setup) para o passo a passo. - -## Segurança — Assinatura HMAC - -Todo webhook é assinado com HMAC-SHA256 usando o `secret`. Headers enviados: +| Tipo | Recebe | +|---|---| +| `cash_in` | Resultado de cobranças SPEI (confirmada, rejeitada, pendente) | +| `cash_out` | Resultado de envios SPEI (liquidado, rejeitado, pendente) | +| `refund_in` | Devolução de um **cash-in** — um pagamento que você recebeu foi estornado ao pagador | +| `refund_out` | Devolução de um **cash-out** — uma transferência que você enviou foi devolvida pela contraparte | +| `all` | **Geral** — uma única URL que recebe todos os eventos acima | + + + Cada webhook assina **exatamente um** tipo. Para receber vários tipos em URLs separadas, crie um webhook por tipo — ou use `all` para centralizar tudo em uma URL e rotear pelo campo `event` do payload. + + +## Payload + +Todo webhook entrega o mesmo formato de payload: + +```json +{ + "event": "transaction.cash_in.settled", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": "CEP20260512A1B2C3", + "occurredAt": "2026-05-12T14:31:05.000Z" +} +``` + + + Tipo do evento: `transaction.cash_in.settled`, `transaction.cash_in.rejected`, `transaction.cash_in.returned`, `transaction.cash_out.settled`, `transaction.cash_out.rejected`, `transaction.cash_out.returned`, ou as variantes `.pending`. Use este campo para rotear o processamento. + + + + Identificador da transação. Correlacione com o `id` retornado na criação da cobrança/transferência. + + + + Valor em centavos MXN. + + + + `LIQUIDATED` (liquidado), `REJECTED` (rejeitado), `RETURNED` (devolvido) ou `PENDING` (em processamento). + + + + CLABEs de destino e origem da transferência. Podem vir `null` dependendo do fluxo. + + + + Referência numérica SPEI e comprovante, quando disponíveis. + + + + Timestamp ISO 8601 do evento. + + +## Headers | Header | Conteúdo | |---|---| -| `X-NTXPay-Signature` | `sha256=` do corpo bruto | -| `X-NTXPay-Timestamp` | timestamp Unix do envio | -| `X-NTXPay-Event` | nome do evento (`cash_in`, etc.) | -| `X-NTXPay-Delivery` | UUID único do envio (use para dedupe) | +| `x-event-id` | UUID único do evento — use para **deduplicar** | +| `X-NTXPay-Signature` | `sha256=` do corpo bruto, assinado com o `secret` do webhook | +| `Content-Type` | `application/json` | -**Sempre valide a assinatura** antes de processar — sem isso, qualquer pessoa pode falsificar notificações. +**Sempre valide a assinatura** antes de processar — sem isso, qualquer pessoa pode falsificar notificações. Veja [Implementação](/pt-br/guides/webhooks/implementation). ## Garantias de Entrega -- **At-least-once**: você pode receber o mesmo evento mais de uma vez. Use `X-NTXPay-Delivery` para deduplicar. -- **Retries**: até 5 tentativas em backoff exponencial se você responder com status diferente de `2xx`. -- **Timeout**: 10 segundos. Responda rápido — processe assincronamente se necessário. -- **Order**: os eventos chegam **fora de ordem** em condições de erro. Confira `createdAt` no payload. +- **At-least-once**: você pode receber o mesmo evento mais de uma vez. Deduplique por `x-event-id`. +- **Retries**: até **5 tentativas** em backoff exponencial (a partir de ~5s) se você responder com status diferente de `2xx`. +- **Timeout**: **10 segundos**. Responda rápido — processe assincronamente se necessário. +- **Ordem**: eventos podem chegar fora de ordem em condições de erro. Confira `occurredAt` no payload. ## Práticas Recomendadas 1. **Responda `200` imediatamente** após validar a assinatura e enfileirar o evento. -2. **Deduplique por `X-NTXPay-Delivery`** ou `transaction.id`. -3. **Idempotência**: processe `cash_in` confirmado para o mesmo `externalId` uma única vez. -4. **Re-consulte** `GET /api/spei/transaction/{externalId}` se tiver dúvida sobre estado — webhook é uma otimização, não a fonte da verdade. +2. **Deduplique por `x-event-id`**. +3. **Roteie pelo campo `event`** — não presuma que uma URL recebe um único tipo (especialmente com `all`). +4. **Trate `status` explicitamente** — implemente os quatro estados (`LIQUIDATED`, `REJECTED`, `RETURNED`, `PENDING`). 5. **HTTPS obrigatório** — webhooks só são enviados a URLs com protocolo HTTPS. ## Próximos Passos - - Configure o endpoint na sua conta + + Registre a URL na sua conta e dispare um webhook de teste Exemplos em Node.js, Python, Java e Go de validação HMAC diff --git a/pt-br/guides/webhooks/refund-in.mdx b/pt-br/guides/webhooks/refund-in.mdx index a314616..0f55118 100644 --- a/pt-br/guides/webhooks/refund-in.mdx +++ b/pt-br/guides/webhooks/refund-in.mdx @@ -1,225 +1,56 @@ --- -title: 'Evento refund_in' -description: 'Notificação de estorno recebido — uma transferência cash-out sua foi devolvida' +title: 'Webhook refund_in' +description: 'Devolução de um cash-in — um pagamento recebido foi estornado ao pagador' mode: 'wide' --- ## Quando dispara -O evento `refund_in` é disparado quando uma transação **cash-out enviada por você** é devolvida pela contraparte. O saldo correspondente é creditado de volta na sua conta. +O webhook do tipo `refund_in` recebe o evento `transaction.cash_in.returned`: um **cash-in que você recebeu foi devolvido ao pagador**. O saldo correspondente é debitado da sua conta. Cenários comuns: -- Beneficiário rejeitou a transferência manualmente -- CLABE existia mas conta foi encerrada após a confirmação inicial -- Estorno solicitado pelo beneficiário dentro do prazo SPEI +- Estorno por motivo de fraude ou erro +- Devolução dentro do prazo da rede SPEI ## Payload ```json { - "event": "refund_in", - "deliveryId": "5b9c2d8e-4f12-4a18-bb29-88a3b4c5d6f7", - "createdAt": "2026-05-14T09:15:00.000Z", - "transaction": { - "id": 67890, - "externalId": "payout-001-refund", - "paymentMethod": "SPEI", - "direction": "in", - "type": "refund_in", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T09:14:50.000Z", - "confirmedAt": "2026-05-14T09:15:00.000Z" - }, - "originalTransactionId": 56789 + "event": "transaction.cash_in.returned", + "transactionId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": "646180123456789012", + "reference": "1234567", + "voucher": null, + "occurredAt": "2026-05-14T11:45:00.000Z" } ``` -O campo `originalTransactionId` aponta para o `id` do cash-out original que foi estornado. Use para correlacionar. +O `transactionId` é o **mesmo** do cash-in original — use-o para localizar o pedido e marcá-lo como estornado. -## Resposta Esperada - -`HTTP 200 OK` em até 10 segundos. - -## Exemplos de Handler - - - -```typescript Node.js -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body for HMAC - -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; - -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'refund_in') { - // Balance credit already happened automatically - markPayoutAsRefunded({ - originalId: event.originalTransactionId, - refundId: event.transaction.id, - amount: event.transaction.amountCentavos, - }); - } - - res.json({ received: true }); -}); -``` - -```python Python -import hmac -import hashlib -import json -import os -from flask import Flask, request, abort, jsonify - -app = Flask(__name__) -SECRET = os.environ["NTXPAY_WEBHOOK_SECRET"].encode() - -@app.post("/webhooks/ntxpay") -def webhook(): - raw = request.get_data() # raw bytes — required for HMAC - sig = request.headers.get("X-NTXPay-Signature", "") - expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() - - if not hmac.compare_digest(sig, expected): - abort(401) - - event = json.loads(raw) - if event["event"] == "refund_in": - # Balance credit already happened automatically - mark_payout_as_refunded( - original_id=event["originalTransactionId"], - refund_id=event["transaction"]["id"], - amount=event["transaction"]["amountCentavos"], - ) - - return jsonify(received=True) -``` +## Headers -```java Java -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +| Header | Valor | +|---|---| +| `x-event-id` | UUID único do evento (use para dedupe) | +| `X-NTXPay-Signature` | `sha256=` do corpo bruto | -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.MessageDigest; -import java.util.Map; - -@RestController -public class NtxPayRefundInHandler { - private static final byte[] SECRET = - System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); - private final ObjectMapper mapper = new ObjectMapper(); - - @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity handle( - @RequestHeader("X-NTXPay-Signature") String sig, - @RequestBody byte[] raw - ) throws Exception { - String expected = "sha256=" + hmacSha256Hex(SECRET, raw); - if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { - return ResponseEntity.status(401).build(); - } - - Map event = mapper.readValue(raw, new TypeReference<>() {}); - if ("refund_in".equals(event.get("event"))) { - Map tx = (Map) event.get("transaction"); - // Balance credit already happened automatically - markPayoutAsRefunded( - ((Number) event.get("originalTransactionId")).longValue(), - ((Number) tx.get("id")).longValue(), - ((Number) tx.get("amountCentavos")).longValue() - ); - } - - return ResponseEntity.ok(Map.of("received", true)); - } - - private static String hmacSha256Hex(byte[] secret, byte[] data) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - byte[] result = mac.doFinal(data); - StringBuilder sb = new StringBuilder(result.length * 2); - for (byte b : result) sb.append(String.format("%02x", b)); - return sb.toString(); - } -} -``` - -```go Go -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "os" -) - -var secret = []byte(os.Getenv("NTXPAY_WEBHOOK_SECRET")) - -type refundInEvent struct { - Event string `json:"event"` - OriginalTransactionID int64 `json:"originalTransactionId"` - Transaction struct { - ID int64 `json:"id"` - AmountCentavos int64 `json:"amountCentavos"` - } `json:"transaction"` -} - -func handleRefundIn(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - - sig := r.Header.Get("X-NTXPay-Signature") - h := hmac.New(sha256.New, secret) - h.Write(raw) - expected := "sha256=" + hex.EncodeToString(h.Sum(nil)) - - if !hmac.Equal([]byte(sig), []byte(expected)) { - w.WriteHeader(http.StatusUnauthorized) - return - } +## Resposta Esperada - var event refundInEvent - if err := json.Unmarshal(raw, &event); err == nil && event.Event == "refund_in" { - // Balance credit already happened automatically - markPayoutAsRefunded( - event.OriginalTransactionID, - event.Transaction.ID, - event.Transaction.AmountCentavos, - ) - } +`200 OK` em até 10 segundos. - _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) -} +## Processamento -func main() { - http.HandleFunc("/webhooks/ntxpay", handleRefundIn) - _ = http.ListenAndServe(":8080", nil) +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_in.returned') { + // O débito do saldo já aconteceu automaticamente + await markOrderRefunded(event.transactionId, event.amount); } ``` - - +Para o handler completo com validação HMAC e dedupe em Node.js, Python, Java e Go, veja [Implementação](/pt-br/guides/webhooks/implementation). diff --git a/pt-br/guides/webhooks/refund-out.mdx b/pt-br/guides/webhooks/refund-out.mdx index c20748e..137552b 100644 --- a/pt-br/guides/webhooks/refund-out.mdx +++ b/pt-br/guides/webhooks/refund-out.mdx @@ -1,224 +1,57 @@ --- -title: 'Evento refund_out' -description: 'Notificação de estorno enviado — você devolveu uma transação cash-in recebida' +title: 'Webhook refund_out' +description: 'Devolução de um cash-out — uma transferência enviada foi devolvida pela contraparte' mode: 'wide' --- ## Quando dispara -O evento `refund_out` é disparado quando um **cash-in que você recebeu é devolvido ao pagador**. O saldo correspondente é debitado da sua conta. +O webhook do tipo `refund_out` recebe o evento `transaction.cash_out.returned`: uma **transferência que você enviou foi devolvida** pelo banco da contraparte. O saldo correspondente é creditado de volta na sua conta. Cenários comuns: -- Você acionou um estorno por motivo de fraude ou erro -- Cliente solicitou cancelamento dentro do prazo SPEI +- CLABE de destino inválida ou conta encerrada +- Beneficiário/banco da contraparte rejeitou a transferência após a aceitação inicial +- Devolução dentro do prazo da rede SPEI ## Payload ```json { - "event": "refund_out", - "deliveryId": "7d2c9e8f-5b34-4c19-aa18-99b3c4d5e6f7", - "createdAt": "2026-05-14T11:45:00.000Z", - "transaction": { - "id": 78901, - "externalId": "order-abc-123-refund", - "paymentMethod": "SPEI", - "direction": "out", - "type": "refund_out", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "012180001234567890", - "createdAt": "2026-05-14T11:44:50.000Z", - "confirmedAt": "2026-05-14T11:45:00.000Z" - }, - "originalTransactionId": 12345 + "event": "transaction.cash_out.returned", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 50000, + "currency": "MXN", + "status": "RETURNED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": null, + "occurredAt": "2026-05-14T09:15:00.000Z" } ``` -`originalTransactionId` aponta para o `id` do cash-in original que foi devolvido. +O `transactionId` é o **mesmo** do cash-out original — use-o para correlacionar e marcar o pagamento como devolvido. -## Resposta Esperada - -`HTTP 200 OK`. - -## Exemplos de Handler - - - -```typescript Node.js -import express from 'express'; -import crypto from 'crypto'; - -const app = express(); -app.use(express.raw({ type: 'application/json' })); // raw body for HMAC - -const SECRET = process.env.NTXPAY_WEBHOOK_SECRET!; - -app.post('/webhooks/ntxpay', (req, res) => { - const sig = req.header('X-NTXPay-Signature') ?? ''; - const expected = 'sha256=' + crypto - .createHmac('sha256', SECRET) - .update(req.body) - .digest('hex'); - - if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { - return res.status(401).end(); - } - - const event = JSON.parse(req.body.toString()); - if (event.event === 'refund_out') { - // Balance already debited - markOrderAsRefunded({ - originalCashInId: event.originalTransactionId, - refundId: event.transaction.id, - amount: event.transaction.amountCentavos, - }); - } - - res.json({ received: true }); -}); -``` - -```python Python -import hmac -import hashlib -import json -import os -from flask import Flask, request, abort, jsonify - -app = Flask(__name__) -SECRET = os.environ["NTXPAY_WEBHOOK_SECRET"].encode() - -@app.post("/webhooks/ntxpay") -def webhook(): - raw = request.get_data() # raw bytes — required for HMAC - sig = request.headers.get("X-NTXPay-Signature", "") - expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() - - if not hmac.compare_digest(sig, expected): - abort(401) - - event = json.loads(raw) - if event["event"] == "refund_out": - # Balance already debited - mark_order_as_refunded( - original_cash_in_id=event["originalTransactionId"], - refund_id=event["transaction"]["id"], - amount=event["transaction"]["amountCentavos"], - ) - - return jsonify(received=True) -``` +## Headers -```java Java -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +| Header | Valor | +|---|---| +| `x-event-id` | UUID único do evento (use para dedupe) | +| `X-NTXPay-Signature` | `sha256=` do corpo bruto | -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.security.MessageDigest; -import java.util.Map; - -@RestController -public class NtxPayRefundOutHandler { - private static final byte[] SECRET = - System.getenv("NTXPAY_WEBHOOK_SECRET").getBytes(); - private final ObjectMapper mapper = new ObjectMapper(); - - @PostMapping(value = "/webhooks/ntxpay", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity handle( - @RequestHeader("X-NTXPay-Signature") String sig, - @RequestBody byte[] raw - ) throws Exception { - String expected = "sha256=" + hmacSha256Hex(SECRET, raw); - if (!MessageDigest.isEqual(sig.getBytes(), expected.getBytes())) { - return ResponseEntity.status(401).build(); - } - - Map event = mapper.readValue(raw, new TypeReference<>() {}); - if ("refund_out".equals(event.get("event"))) { - Map tx = (Map) event.get("transaction"); - // Balance already debited - markOrderAsRefunded( - ((Number) event.get("originalTransactionId")).longValue(), - ((Number) tx.get("id")).longValue(), - ((Number) tx.get("amountCentavos")).longValue() - ); - } - - return ResponseEntity.ok(Map.of("received", true)); - } - - private static String hmacSha256Hex(byte[] secret, byte[] data) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret, "HmacSHA256")); - byte[] result = mac.doFinal(data); - StringBuilder sb = new StringBuilder(result.length * 2); - for (byte b : result) sb.append(String.format("%02x", b)); - return sb.toString(); - } -} -``` - -```go Go -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" - "net/http" - "os" -) - -var secret = []byte(os.Getenv("NTXPAY_WEBHOOK_SECRET")) - -type refundOutEvent struct { - Event string `json:"event"` - OriginalTransactionID int64 `json:"originalTransactionId"` - Transaction struct { - ID int64 `json:"id"` - AmountCentavos int64 `json:"amountCentavos"` - } `json:"transaction"` -} - -func handleRefundOut(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - - sig := r.Header.Get("X-NTXPay-Signature") - h := hmac.New(sha256.New, secret) - h.Write(raw) - expected := "sha256=" + hex.EncodeToString(h.Sum(nil)) - - if !hmac.Equal([]byte(sig), []byte(expected)) { - w.WriteHeader(http.StatusUnauthorized) - return - } +## Resposta Esperada - var event refundOutEvent - if err := json.Unmarshal(raw, &event); err == nil && event.Event == "refund_out" { - // Balance already debited - markOrderAsRefunded( - event.OriginalTransactionID, - event.Transaction.ID, - event.Transaction.AmountCentavos, - ) - } +`200 OK` em até 10 segundos. - _ = json.NewEncoder(w).Encode(map[string]bool{"received": true}) -} +## Processamento -func main() { - http.HandleFunc("/webhooks/ntxpay", handleRefundOut) - _ = http.ListenAndServe(":8080", nil) +```typescript +const event = JSON.parse(rawBody.toString()); +if (event.event === 'transaction.cash_out.returned') { + // O crédito do saldo de volta já aconteceu automaticamente + await markPayoutReturned(event.transactionId, event.amount); } ``` - - +Para o handler completo com validação HMAC e dedupe em Node.js, Python, Java e Go, veja [Implementação](/pt-br/guides/webhooks/implementation). diff --git a/pt-br/guides/webhooks/setup.mdx b/pt-br/guides/webhooks/setup.mdx index ba5f77c..7285ddb 100644 --- a/pt-br/guides/webhooks/setup.mdx +++ b/pt-br/guides/webhooks/setup.mdx @@ -1,15 +1,16 @@ --- -title: 'Setup de Webhooks' -description: 'Configure URLs de webhook programaticamente para SPEI' +title: 'Configuração de Webhooks' +description: 'Registre, teste, liste e remova URLs de webhook programaticamente' mode: 'wide' --- ## Visão Geral -A configuração de webhooks é feita via três endpoints: +A configuração de webhooks é feita via quatro endpoints: - `GET /api/webhooks-config` — listar webhooks ativos - `POST /api/webhooks-config` — criar/configurar um webhook +- `POST /api/webhooks-config/test` — disparar um webhook de teste assinado - `DELETE /api/webhooks-config/{id}` — remover um webhook ## Criar Webhook @@ -50,13 +51,57 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ - Um webhook assina **exatamente UM** evento — o array deve conter um único item. Valores aceitos: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `internal_transfer`. Para receber mais de um tipo de evento, crie um webhook por evento. + Um webhook assina **exatamente UM** evento — o array deve conter um único item. Valores aceitos: `cash_in`, `cash_out`, `refund_in`, `refund_out`, `all` (Geral — recebe todos os eventos) e `internal_transfer`. Veja a semântica de cada tipo na [Visão Geral](/pt-br/guides/webhooks/overview). Secret HMAC para validar assinatura. Mínimo 8 caracteres, máximo 128. Se omitido, o NTX Pay gera. +## Webhook de Teste + +Depois de criar o webhook, dispare uma entrega de teste **assinada com o mesmo secret** — sem precisar movimentar uma transação: + +```bash +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "eventType": "cash_in", + "status": "LIQUIDATED" + }' +``` + +```json +{ + "delivered": true, + "url": "https://meu-servidor.com/webhooks/ntxpay", + "eventId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", + "status": "LIQUIDATED", + "signed": true, + "statusCode": 200, + "timeMs": 184 +} +``` + + + Qual webhook recebe o teste: `cash_in`, `cash_out`, `refund_in`, `refund_out` ou `internal_transfer`. + + + + Status simulado no payload: `LIQUIDATED` (default), `PENDING`, `REJECTED` ou `RETURNED`. + + + + URL temporária de teste (ex.: webhook.site). Se omitida, entrega na URL configurada. + + + + Valor em centavos no payload de teste (default `1000` = $10,00 MXN). + + +`delivered: true` significa que o seu endpoint respondeu `2xx`. `statusCode: 0` indica erro de conexão. + ## Listar Webhooks ```bash @@ -100,22 +145,21 @@ curl -X DELETE https://sandbox.mx.ntxpay.com/api/webhooks-config/42 \ ## Múltiplos Webhooks -Cada webhook assina exatamente um evento, então configure **um webhook por tipo de evento** que deseja receber (ex.: um para `cash_in`, outro para `cash_out`). Útil para: +Cada webhook assina exatamente um evento, então você tem duas estratégias: -- Rotear cada tipo de evento para seu próprio endpoint/handler -- Múltiplos serviços consumindo eventos diferentes -- Ambientes internos distintos (ex.: dev vs homologação interna) +- **Um webhook por tipo** (ex.: um para `cash_in`, outro para `cash_out`) — roteia cada tipo para seu próprio endpoint/handler. +- **Um webhook `all`** — uma URL única recebe tudo e o seu handler roteia pelo campo `event` do payload. -## Testando o Endpoint +## Validando o Endpoint -Antes de liberar o webhook para receber tráfego de verdade, valide seu endpoint: +Antes de liberar o webhook para receber tráfego de verdade: -1. Use [webhook.site](https://webhook.site) ou [ngrok](https://ngrok.com) para inspecionar o tráfego -2. Dispare cobranças com `X-Sandbox-Scenario` (veja [Cenários de Sandbox](/pt-br/sandbox/scenarios)) +1. Use [webhook.site](https://webhook.site) ou [ngrok](https://ngrok.com) para inspecionar o tráfego (o campo `overrideUrl` do webhook de teste aceita essas URLs) +2. Dispare entregas com `POST /api/webhooks-config/test` variando o `status` 3. Confira que sua aplicação: - Valida `X-NTXPay-Signature` corretamente - Retorna `200` em menos de 10 segundos - - Deduplica por `X-NTXPay-Delivery` + - Deduplica por `x-event-id` ## Próximos Passos diff --git a/pt-br/index.mdx b/pt-br/index.mdx index 54161cb..00d801a 100644 --- a/pt-br/index.mdx +++ b/pt-br/index.mdx @@ -3,7 +3,7 @@ title: 'API NTX Pay México' description: 'Integração com SPEI em uma única API' --- -Gateway público para integração com SPEI (transferências interbancárias instantâneas). Receba e envie via SPEI, consulte saldo e transações, receba webhooks assinados. +Gateway público para integração com SPEI (transferências interbancárias instantâneas). Receba e envie via SPEI, consulte saldo, receba webhooks assinados. ## Ambientes diff --git a/pt-br/sandbox/authentication.mdx b/pt-br/sandbox/authentication.mdx deleted file mode 100644 index 9a3a6ad..0000000 --- a/pt-br/sandbox/authentication.mdx +++ /dev/null @@ -1,81 +0,0 @@ ---- -title: 'Autenticação' -description: 'Autenticação em sandbox é estruturalmente idêntica à produção.' -mode: 'wide' ---- - -## Visão Geral - -A autenticação em sandbox usa as mesmas duas camadas que produção: - -1. **Certificado X.509 (mTLS)** — entregue pelo NTX Pay no onboarding. -2. **OAuth 2.0 `client_credentials`** — `clientId` + `clientSecret` fornecidos no onboarding. - -Em conjunto, retornam um **JWT** (validade 10 minutos) usado nos demais endpoints como `Authorization: Bearer ...`. - - - As credenciais de sandbox são **distintas** das de produção. Se você usar credenciais de produção contra `https://sandbox.mx.ntxpay.com`, receberá `401`. O contrato HTTP é idêntico — o que muda é o par certificado + clientId/clientSecret. - - -## Obter Token - -### POST /api/auth/token - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/auth/token \ - -H "X-SSL-Client-Cert: $ENCODED_CERT" \ - -H "Content-Type: application/json" \ - -d '{ - "clientId": "qr-93-550e8400", - "clientSecret": "a1b2c3d4e5f6g7h8" - }' -``` - -#### Response (201) - -```json -{ - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - "token_type": "Bearer", - "expires_in": 600, - "scope": "email profile" -} -``` - -## Usar o Token - -Em uma conta sandbox, qualquer chamada autenticada simula um pipeline completo sem mover dinheiro real: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "externalId": "test-001" - }' -``` - -A resposta é sempre `201 Created` com `status: PENDING`. O resultado final (confirmação ou falha) chega via webhook após ~1 segundo. Veja [Cenários](/pt-br/sandbox/scenarios) para forçar resultados específicos. - -## Renovação - -O token expira em **10 minutos (600s)**. Não há refresh token — gere um novo via `POST /api/auth/token` antes de expirar. - - - Em alta carga, gere um token por worker e renove a cada ~8 minutos para evitar `401` por expiração. - - -## Erros Comuns - -| Código | Causa | Solução | -|---|---|---| -| `400` | `X-SSL-Client-Cert` ausente | Configure NGINX/ALB para repassar o certificado | -| `401` | `clientId`/`clientSecret` inválido | Reconfira credenciais; certifique-se de estar usando as de sandbox | -| `401` | Certificado expirado/revogado | Solicite renovação ao NTX Pay | - -## Documentação detalhada - -Para o passo a passo completo (encoding do certificado, exemplos em múltiplas linguagens, etc.) veja [Autenticação](/pt-br/guides/authentication) no guia geral — a única diferença é a base URL `https://sandbox.mx.ntxpay.com`. diff --git a/pt-br/sandbox/cash-in.mdx b/pt-br/sandbox/cash-in.mdx deleted file mode 100644 index 209c508..0000000 --- a/pt-br/sandbox/cash-in.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: 'Cash-in (recebimento SPEI)' -description: 'Como gerar CLABE descartável de cobrança no sandbox.' -mode: 'wide' ---- - -## O que faz - -`POST /api/spei/cash-in` gera uma **CLABE descartável** vinculada à sua conta sandbox. Qualquer transferência SPEI recebida nessa CLABE dispara um webhook `cash_in` para a URL configurada. - -No sandbox, a confirmação é **simulada** ~1 segundo após a criação da CLABE (em vez de aguardar uma transferência real). Isso permite testar todo o fluxo de cash-in sem depender de um banco emissor. - -## Exemplo - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-in \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 50000, - "externalId": "order-001", - "customerName": "Juan Pérez", - "customerEmail": "juan@example.com" - }' -``` - -### Response (201) - -```json -{ - "id": 12345, - "externalId": "order-001", - "status": "PENDING", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "expiresAt": "2026-03-26T10:30:00.000Z" -} -``` - -Use o `clabe` retornado para exibir ao pagador final (cliente da sua empresa). No sandbox, esta CLABE é fictícia mas o objeto `transaction.clabe` que chega no webhook será **o mesmo**. - -## Webhook esperado - -Após ~1 segundo (cenário `success` padrão), você recebe: - -```json -{ - "event": "cash_in", - "deliveryId": "...", - "transaction": { - "id": 12345, - "externalId": "order-001", - "status": "CONFIRMED", - "amountCentavos": 50000, - "clabe": "646180123456789012", - "confirmedAt": "2026-03-26T10:00:01.000Z", - "counterpart": { - "name": "Pagador Simulado", - "taxId": "PAGS850101ABC", - "bank": { - "code": "012", - "name": "BBVA México" - } - } - } -} -``` - -## Cenários de teste - -| Cenário | Webhook | -|---|---| -| `success` (default) | `CONFIRMED` | -| `pending_long` | `CONFIRMED` após ~30s | -| `rejected` | `FAILED` | -| `returned` | `RETURNED` | - -`timeout` e `provider_5xx` falham na resposta HTTP síncrona. `insufficient_funds` e `bad_clabe` **não se aplicam** a cash-in (retornam `400 SCENARIO_NOT_APPLICABLE`). - -Veja [Cenários](/pt-br/sandbox/scenarios) para a lista completa. - -## Próximos passos - - - - Como enviar SPEI no sandbox. - - - Entendendo a entrega de webhooks no sandbox. - - diff --git a/pt-br/sandbox/cash-out.mdx b/pt-br/sandbox/cash-out.mdx deleted file mode 100644 index 4dc2aec..0000000 --- a/pt-br/sandbox/cash-out.mdx +++ /dev/null @@ -1,102 +0,0 @@ ---- -title: 'Cash-out (envio SPEI)' -description: 'Como enviar SPEI a uma CLABE de destino no sandbox.' -mode: 'wide' ---- - -## O que faz - -`POST /api/spei/cash-out` solicita o envio de SPEI a uma CLABE de destino. No sandbox, o pipeline contábil completo é exercitado — saldo é debitado, tarifa é cobrada, registro no extrato é gerado — mas a chamada ao Banxico é simulada. - -A resposta HTTP é sempre `201 Created` com `status: PENDING`. O resultado final chega via webhook `cash_out` ~1 segundo depois (cenário `success`) ou conforme o cenário forçado. - -## Pré-requisito - -Sua conta sandbox precisa ter saldo. Faça pelo menos um [cash-in](/pt-br/sandbox/cash-in) antes — o saldo simulado é debitado igual produção. - -## Exemplo - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "amountCentavos": 15000, - "destinationClabe": "012180001234567890", - "beneficiaryName": "Maria Lopez", - "beneficiaryTaxId": "LOPM850101ABC", - "concept": "Pagamento de fatura" - }' -``` - -### Response (201) - -```json -{ - "id": 12346, - "status": "PENDING", - "destinationClabe": "012180001234567890", - "amountCentavos": 15000, - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T10:00:00.000Z" -} -``` - -## Webhook esperado - -Após ~1 segundo (cenário `success`): - -```json -{ - "event": "cash_out", - "deliveryId": "...", - "transaction": { - "id": 12346, - "externalId": "payout-001", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "confirmedAt": "2026-03-26T10:00:01.000Z" - }, - "errorCode": null, - "errorMessage": null -} -``` - -## Cenários de erro úteis - -Force comportamentos específicos via header `X-Sandbox-Scenario`: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: insufficient_funds" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -| Cenário | Quando usar | Webhook | -|---|---|---| -| `insufficient_funds` | Validar UX quando seu cliente tenta pagar sem saldo | `FAILED` (`errorCode: INSUFFICIENT_FUNDS`) | -| `bad_clabe` | Validar tratamento de devolução por CLABE inválida | `RETURNED` (`errorCode: INVALID_CLABE`) | -| `rejected` | Validar rejeição genérica da rede SPEI | `FAILED` | -| `returned` | Validar transferência estornada pelo banco contraparte | `RETURNED` | -| `pending_long` | Validar UX de "transferência em andamento" (~30s) | `CONFIRMED` | -| `timeout` / `provider_5xx` | Validar falhas upstream síncronas (`504` / `503`) | — (erro síncrono) | - -Veja [Cenários](/pt-br/sandbox/scenarios) para a lista completa. - -## Validações síncronas - -Mesmo no sandbox, algumas validações ocorrem **antes** do retorno `201`: - -| Erro síncrono | HTTP | Quando | -|---|---|---| -| `400 INVALID_AMOUNT` | `400` | `amountCentavos <= 0` | -| `400 INVALID_CLABE_FORMAT` | `400` | CLABE com formato inválido (não exatamente 18 dígitos) | -| `400 INSUFFICIENT_FUNDS` | `400` | Saldo real abaixo de `amountCentavos + tarifa` (sem usar cenário) | - - - Os cenários `insufficient_funds`, `bad_clabe`, `rejected` e `returned` afetam o **webhook** — a resposta síncrona é `201 PENDING`. `timeout` e `provider_5xx` falham síncronos, assim como as validações estruturais (valor/formato de CLABE). - diff --git a/pt-br/sandbox/introduction.mdx b/pt-br/sandbox/introduction.mdx index 121c745..0d5c5d6 100644 --- a/pt-br/sandbox/introduction.mdx +++ b/pt-br/sandbox/introduction.mdx @@ -1,36 +1,32 @@ --- title: 'Sandbox NTX Pay' -description: 'Ambiente de teste com alta fidelidade ao pipeline de produção do NTX Pay México.' +description: 'Ambiente de teste com alta fidelidade ao comportamento de produção.' mode: 'wide' --- ## O que é -O sandbox NTX Pay permite que sua integração exercite **cash-in**, **cash-out**, **refund** e **webhooks** sem mover dinheiro real. Diferente de mocks simplistas, o pipeline contábil completo (saldo TigerBeetle, validação de limites, cobrança de tarifas, geração de extratos, entrega de webhooks via outbox) é exercitado intacto. Apenas a liquidação na rede SPEI é simulada. +O sandbox NTX Pay permite que sua integração exercite **cash-in**, **cash-out**, **devoluções** e **webhooks** sem mover dinheiro real. O pipeline completo é exercitado — saldo, validação de limites, cobrança de tarifas, extrato e entrega de webhooks — apenas a liquidação na rede SPEI é simulada. - Toda integração com a NTX Pay começa pelo sandbox. Os endpoints, payloads e webhooks descritos nesta documentação são os definitivos — quando produção for liberada para a sua empresa, o mesmo código funcionará apenas trocando as credenciais. + **A integração não muda.** Os endpoints, payloads e webhooks são exatamente os descritos nos [guias](/pt-br/guides/get-started) — quando produção for liberada para a sua empresa, o mesmo código funcionará apenas trocando as credenciais. Por isso, esta seção documenta somente **o que é diferente no sandbox**: os [cenários de teste](/pt-br/sandbox/scenarios) e os [webhooks simulados](/pt-br/sandbox/webhooks). ## Como ativar Suas credenciais de API são as **mesmas estruturalmente** que você usaria em produção. A diferença vive na conta: contas sandbox roteiam as chamadas SPEI para o simulador interno da NTX Pay. Para criar uma conta sandbox, peça ao seu Account Manager ou escreva para `contact@ntxpay.com` — o onboarding é instantâneo e o KYC é auto-aprovado. + + As credenciais de sandbox são **distintas** das de produção. Credenciais de produção contra o host de sandbox retornam `401`. A [autenticação](/pt-br/guides/authentication) em si é idêntica. + + ## Base URL | Ambiente | URL | |---|---| | Sandbox | `https://sandbox.mx.ntxpay.com` | -Todas as rotas documentadas (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/transactions`, `/api/webhooks-config`) estão disponíveis exatamente neste host. - -## Cenários de teste - -Você controla o comportamento de cada chamada via header HTTP `X-Sandbox-Scenario`. Sem o header, o sandbox retorna **sucesso** por padrão. Veja [Cenários](/pt-br/sandbox/scenarios) para a lista completa de cenários de erro, sucesso e atraso suportados. - -## Webhooks - -Registre seu `webhookUrl` na conta sandbox exatamente como faria em produção — via `POST /api/webhooks-config`. Os eventos são entregues pelo mesmo motor de outbox que usamos em prod, com as mesmas assinaturas, headers (`X-NTXPay-Delivery`) e política de retry. +Todas as rotas documentadas (`/api/auth/token`, `/api/spei/cash-in`, `/api/spei/cash-out`, `/api/balance`, `/api/webhooks-config`) estão disponíveis exatamente neste host. ## Diferenças vs Produção @@ -38,27 +34,26 @@ Registre seu `webhookUrl` na conta sandbox exatamente como faria em produção |---|---|---| | Base URL | `https://sandbox.mx.ntxpay.com` | Fornecida no onboarding | | Saldo | Simulado | Fundos reais | -| Confirmação SPEI cash-in | Imediata (~1s) | Real (segundos a minutos) | -| `X-Sandbox-Scenario` | Suportado | Rejeitado com `400` | +| Liquidação SPEI | Simulada, em segundos | Real (segundos a minutos) | +| Header `X-Sandbox-Scenario` | Suportado | Rejeitado com `400` | | Custo | Gratuito | Conforme contrato | +## Fluxo de teste sugerido + +1. **Autentique** — [obtenha o JWT](/pt-br/guides/authentication) com as credenciais sandbox. +2. **Registre seu webhook** — via [`POST /api/webhooks-config`](/pt-br/guides/webhooks/setup), exatamente como em produção. +3. **Crie um cash-in** — siga o [guia de cash-in](/pt-br/guides/spei-cash-in); a confirmação chega simulada em segundos. +4. **Envie um cash-out** — com o saldo do passo anterior, siga o [guia de cash-out](/pt-br/guides/spei-cash-out). +5. **Force erros e devoluções** — use os [cenários](/pt-br/sandbox/scenarios) para exercitar todos os caminhos do seu handler. + ## Próximos passos - - Como obter o JWT no sandbox usando suas credenciais. - - - Lista completa de cenários disponíveis via `X-Sandbox-Scenario`. - - - Receber via SPEI no sandbox. - - - Enviar via SPEI no sandbox. + + Force sucesso, falha, devolução e atraso via header `X-Sandbox-Scenario`. - - Como o sandbox entrega webhooks e como testar dedupe. + + Como disparar cada evento e validar dedupe, retry e assinatura. diff --git a/pt-br/sandbox/scenarios.mdx b/pt-br/sandbox/scenarios.mdx index 98de565..aca3ee4 100644 --- a/pt-br/sandbox/scenarios.mdx +++ b/pt-br/sandbox/scenarios.mdx @@ -21,7 +21,7 @@ curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ ``` - A maioria dos cenários controla o **webhook assíncrono**: a resposta HTTP é `201 Created` com `status: PENDING`, e o resultado final (`CONFIRMED`, `FAILED` ou `RETURNED`) chega no webhook. As exceções são `timeout` e `provider_5xx`, que falham na resposta HTTP **síncrona**. + A maioria dos cenários controla o **webhook assíncrono**: a resposta HTTP é `201 Created` com `status: PENDING`, e o resultado final chega no webhook. As exceções são `timeout` e `provider_5xx`, que falham na resposta HTTP **síncrona**. ## Cenários disponíveis @@ -30,16 +30,20 @@ Os valores canônicos de cenário são: `success`, `pending_long`, `rejected`, ` ### Cenários de resultado assíncrono -Retornam `201 PENDING` de forma síncrona; o estado final chega via webhook. +Retornam `201 PENDING` de forma síncrona; o estado final chega via webhook em segundos. -| Header Value | Resultado do webhook | Notas | +| Header Value | Webhook resultante | Notas | |---|---|---| -| `success` (default) | `CONFIRMED` em ~1s | Também usado quando nenhum header é enviado | -| `pending_long` | `CONFIRMED` após ~30s | Testa settlement lento | -| `rejected` | `FAILED` | A rede SPEI rejeitou a transferência | -| `returned` | `RETURNED` | Aceita e depois devolvida pelo banco contraparte | -| `insufficient_funds` | `FAILED` com `errorCode: INSUFFICIENT_FUNDS` | **Apenas cash-out** | -| `bad_clabe` | `RETURNED` com `errorCode: INVALID_CLABE` | **Apenas cash-out** — aceita e depois devolvida | +| `success` (default) | `*.settled` — `status: LIQUIDATED` | Também usado quando nenhum header é enviado | +| `pending_long` | `*.settled` — `status: LIQUIDATED` após ~30s | Testa liquidação lenta | +| `rejected` | `*.rejected` — `status: REJECTED` | A rede SPEI rejeitou a transferência | +| `returned` | `*.returned` — `status: RETURNED` | Aceita e depois devolvida pela contraparte | +| `insufficient_funds` | `cash_out.rejected` — `status: REJECTED` | **Apenas cash-out** — simula rejeição por falta de fundos | +| `bad_clabe` | `cash_out.returned` — `status: RETURNED` | **Apenas cash-out** — aceita e devolvida por CLABE inválida | + + + Os eventos `*.returned` são entregues no webhook do tipo **`refund_in`/`refund_out`** (ou `all`), não no `cash_in`/`cash_out`. Para testar os cenários `returned` e `bad_clabe`, registre também esses webhooks — veja [tipos de webhook](/pt-br/guides/webhooks/overview). + ### Cenários de erro síncrono @@ -47,7 +51,7 @@ Falham na própria resposta HTTP — nenhum webhook é enviado. | Header Value | Resposta síncrona | |---|---| -| `timeout` | Timeout upstream (`504`) após ~16s | +| `timeout` | Timeout no processamento (`504`) após ~16s | | `provider_5xx` | Serviço temporariamente indisponível (`503`) | @@ -56,64 +60,43 @@ Falham na própria resposta HTTP — nenhum webhook é enviado. ## Exemplo: webhook de sucesso +Cenário `success` em um cash-out — o webhook `cash_out` recebe: + ```json { - "event": "cash_out", - "deliveryId": "8e2c5b6f-3a12-4b9c-9a18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:00:00.000Z", - "transaction": { - "id": 12345, - "externalId": "test-success-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "CONFIRMED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": "9876543", - "createdAt": "2026-03-26T09:59:59.000Z", - "confirmedAt": "2026-03-26T10:00:00.000Z", - "counterpart": { - "name": "Maria Lopez", - "taxId": null, - "bank": {} - } - }, - "errorCode": null, - "errorMessage": null, - "metadata": {} + "event": "transaction.cash_out.settled", + "transactionId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", + "amount": 15000, + "currency": "MXN", + "status": "LIQUIDATED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": "9876543", + "voucher": "CEP20260326G7H8I9", + "occurredAt": "2026-03-26T10:00:00.000Z" } ``` -## Exemplo: webhook de falha +## Exemplo: webhook de rejeição + +Cenário `insufficient_funds` — o webhook `cash_out` recebe: ```json { - "event": "cash_out", - "deliveryId": "1a3f9e8d-2c47-4b9c-aa18-77a2b3c4d5e6", - "createdAt": "2026-03-26T10:01:00.000Z", - "transaction": { - "id": 12346, - "externalId": "test-error-001", - "paymentMethod": "SPEI", - "direction": "out", - "type": "cash_out", - "status": "FAILED", - "amountCentavos": 15000, - "clabe": "012180001234567890", - "referenceNumerical": null, - "confirmedAt": null - }, - "errorCode": "INSUFFICIENT_FUNDS", - "errorMessage": "Conta sem saldo suficiente", - "metadata": {} + "event": "transaction.cash_out.rejected", + "transactionId": "2b4c8d9e-3f56-4a1b-bc29-88a3b4c5d6f7", + "amount": 15000, + "currency": "MXN", + "status": "REJECTED", + "destinationClabe": "012180001234567890", + "sourceClabe": null, + "reference": null, + "voucher": null, + "occurredAt": "2026-03-26T10:01:00.000Z" } ``` -Notas: - -- Em `status: FAILED`, `referenceNumerical` e `confirmedAt` são `null` — a rede SPEI nunca confirmou a transação. -- `errorCode` e `errorMessage` descrevem o motivo da falha. +Em `status: REJECTED`, os campos de comprovante (`reference`, `voucher`) vêm `null` — a rede SPEI nunca confirmou a transação. O formato completo do payload está em [Visão Geral de Webhooks](/pt-br/guides/webhooks/overview). ## Restrições @@ -129,7 +112,7 @@ Notas: ## Boas práticas -1. **Teste todos os cenários** antes de ir ao ar — implemente o tratamento de `CONFIRMED`, `PENDING`, `FAILED` e `EXPIRED`. -2. **Valide os campos de erro** — use `errorCode` para decisões automáticas; reserve `errorMessage` para logs/usuários. -3. **Teste com delay** — verifique que seu sistema lida bem com entrega lenta do webhook. -4. **Idempotência** — use `transaction.id` como chave de idempotência; o mesmo webhook pode ser reenviado. +1. **Teste todos os cenários** antes de ir ao ar — implemente o tratamento dos quatro status (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`). +2. **Roteie pelo campo `event`** — `*.settled`, `*.rejected` e `*.returned` exigem ações diferentes no seu sistema. +3. **Teste com delay** — use `pending_long` para verificar que seu sistema lida bem com liquidação lenta. +4. **Idempotência** — deduplique pelo header `x-event-id`; o mesmo evento pode ser reentregue. diff --git a/pt-br/sandbox/webhooks.mdx b/pt-br/sandbox/webhooks.mdx index 643887c..37493b1 100644 --- a/pt-br/sandbox/webhooks.mdx +++ b/pt-br/sandbox/webhooks.mdx @@ -1,58 +1,64 @@ --- -title: 'Webhooks' -description: 'Como o sandbox entrega webhooks e como testar dedupe, retry e assinatura.' +title: 'Webhooks simulados' +description: 'Como disparar cada evento no sandbox e validar dedupe, retry e assinatura.' mode: 'wide' --- ## Como funciona -O sandbox usa o **mesmo motor de outbox** que produção. Isso significa: +O sandbox usa o **mesmo motor de entrega** que produção: -- Mesma estrutura de payload -- Mesmos headers (`X-NTXPay-Delivery`, `X-NTXPay-Signature`, etc.) -- Mesma política de retry exponencial +- Mesma estrutura de payload — veja o [contrato completo](/pt-br/guides/webhooks/overview) +- Mesmos headers (`x-event-id`, `X-NTXPay-Signature`) +- Mesma política de retry (5 tentativas, backoff exponencial, timeout 10s) - Mesmo formato de assinatura HMAC -A única diferença é a **velocidade**: webhooks de sandbox são disparados ~1 segundo após a request (vs. minutos em produção), e você pode forçar atrasos artificiais via cenário `delayed:`. +A diferença é a **origem**: em vez de aguardar liquidação real na rede SPEI, o simulador resolve a transação em segundos — e você controla o desfecho via [cenários](/pt-br/sandbox/scenarios). -## Registrar URL +A [configuração do webhook](/pt-br/guides/webhooks/setup) é idêntica à de produção — registre a URL via `POST /api/webhooks-config` normalmente. + +## Duas formas de disparar um webhook + +### 1. Webhook de teste (sem transação) + +O jeito mais rápido de validar seu endpoint — dispara uma entrega assinada sem movimentar nada: ```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config \ +curl -X POST https://sandbox.mx.ntxpay.com/api/webhooks-config/test \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ - "url": "https://meu-servidor.com/webhooks/ntxpay", - "events": ["cash_in"] + "eventType": "cash_in", + "status": "LIQUIDATED" }' ``` -### Response (201) +A resposta informa na hora se o seu endpoint respondeu `2xx`, o tempo de resposta e a assinatura enviada. Varie o `status` (`LIQUIDATED`, `PENDING`, `REJECTED`, `RETURNED`) para exercitar cada caminho do handler. Detalhes dos campos em [Configuração](/pt-br/guides/webhooks/setup#webhook-de-teste). -```json -{ - "id": "wh_550e8400", - "url": "https://meu-servidor.com/webhooks/ntxpay", - "events": ["cash_in"], - "secret": "whsec_a1b2c3d4...", - "createdAt": "2026-03-26T09:00:00.000Z" -} -``` +### 2. Transação simulada (fluxo completo) -Guarde o `secret` retornado — ele é usado para verificar a assinatura HMAC. **Ele só é exibido uma vez.** +Crie um [cash-in](/pt-br/guides/spei-cash-in) ou [cash-out](/pt-br/guides/spei-cash-out) com o header `X-Sandbox-Scenario` — o pipeline inteiro roda (saldo, tarifa, extrato) e o webhook chega em segundos com o desfecho escolhido: -## Eventos disponíveis +| Para receber | Use o cenário | No webhook tipo | +|---|---|---| +| `*.settled` (`LIQUIDATED`) | `success` (ou nenhum header) | `cash_in` / `cash_out` | +| `*.settled` com atraso ~30s | `pending_long` | `cash_in` / `cash_out` | +| `*.rejected` (`REJECTED`) | `rejected` ou `insufficient_funds` | `cash_in` / `cash_out` | +| `*.returned` (`RETURNED`) | `returned` ou `bad_clabe` | `refund_in` / `refund_out` | -| Evento | Disparado quando | -|---|---| -| `cash_in` | CLABE descartável recebe uma transferência (simulada) | -| `cash_out` | Envio SPEI é resolvido (confirmado ou falhado) | -| `refund_in` | Refund de cash-in é processado | -| `refund_out` | Refund de cash-out é processado | +Veja o [catálogo completo de cenários](/pt-br/sandbox/scenarios). -## Verificar assinatura +## Testar dedupe -Cada webhook chega com o header `X-NTXPay-Signature` no formato `sha256=`: +Cada entrega carrega um `x-event-id` único. Para testar seu dedupe: + +1. Configure seu handler para retornar `500` na primeira tentativa. +2. O NTX Pay vai entregar a mesma mensagem novamente (com o **mesmo** `x-event-id`). +3. Confirme que seu sistema ignora a duplicata e responde `200` na segunda tentativa. + +## Testar a assinatura + +Aponte um webhook de teste para o seu endpoint e valide o `X-NTXPay-Signature` com o `secret` retornado na criação: ```python import hmac @@ -67,45 +73,11 @@ def verify(payload_bytes: bytes, signature_header: str, secret: str) -> bool: return hmac.compare_digest(expected, signature_header) ``` -## Testar dedupe - -Cada entrega tem um `deliveryId` único no header `X-NTXPay-Delivery` e dentro do payload. Para testar seu dedupe: - -1. Configure seu handler para retornar `500` na primeira tentativa. -2. O NTX Pay vai entregar a mesma mensagem novamente (com o **mesmo** `deliveryId`). -3. Confirme que seu sistema ignora a duplicata e responde `200` na segunda tentativa. - -## Política de retry - -| Tentativa | Atraso após anterior | -|---|---| -| 1 | imediato | -| 2 | 30s | -| 3 | 2min | -| 4 | 10min | -| 5 | 1h | -| 6 | 6h | -| 7+ | desistido | - -Seu endpoint precisa responder `2xx` em até **5 segundos** — qualquer `5xx`, timeout ou erro de conexão dispara retry. - -## Cenários de teste - -Force o webhook sair como `FAILED` ou com delay: - -```bash -curl -X POST https://sandbox.mx.ntxpay.com/api/spei/cash-out \ - -H "Authorization: Bearer $TOKEN" \ - -H "X-Sandbox-Scenario: pending_long" \ - -H "Content-Type: application/json" \ - -d '{ ... }' -``` - -Veja [Cenários](/pt-br/sandbox/scenarios) para o catálogo completo. +Handlers completos em Node.js, Python, Java e Go: [Implementação](/pt-br/guides/webhooks/implementation). ## Boas práticas -1. **Responda 200 antes de processar** — enfileire o evento em background; cinco segundos é o teto. -2. **Use `deliveryId` para dedupe** — não confie em `transaction.id` (retries chegam com o mesmo `transaction.id` mas `deliveryId` novo no caso de redrive manual). +1. **Valide a assinatura** — sempre, mesmo em sandbox. +2. **Use `x-event-id` para dedupe** — o mesmo evento pode ser reentregue. 3. **Não dependa da ordem** — webhooks podem chegar fora de ordem após retries. -4. **Valide a assinatura** — sempre, mesmo em sandbox. +4. **Exercite os quatro status** antes de ir para produção — o sandbox existe para isso.