> **TX Pay — API de pagos (v1) — documentación completa para agentes de IA.** > Generado automáticamente desde la guía del Developer Portal (fuente única). > > Los ejemplos de este archivo usan los valores de **producción**: > base URL `https://api.txncompany.com` · claves `sk_live_…` · `livemode: true`. > > Equivalentes de **sandbox** (pruebas, sin dinero real; úsalo para integrar primero): > base URL `https://api-dev.txncompany.com` · claves `sk_test_…` · `livemode: false`. > > Índice corto: https://app.txncompany.com/llms.txt · OpenAPI: https://api.txncompany.com/api/v1/openapi.json # API de pagos TX Pay (v1) Integra pagos en tu sitio con **Hosted Checkout**: tu servidor crea una *checkout session*, rediriges al comprador a la URL de pago que te entregamos, paga en nuestra página/proveedor y vuelve a tu sitio. **Nunca manejas datos de tarjeta, así que quedas fuera del alcance PCI.** Usa el selector de **entorno** (Sandbox / Producción) en la parte superior: los ejemplos de código de abajo se ajustan automáticamente con la URL base, el prefijo de la clave (`sk_test_…` / `sk_live_…`) y el valor de `livemode` del entorno seleccionado. ## Integra con IA (docs listas para agentes) > **LISTO PARA IA.** Esta documentación también existe en formato **legible por máquinas**, pensada para que un asistente de programación con IA (Claude Code, Cursor, Copilot, ChatGPT, etc.) integre TX Pay por ti a partir de la misma fuente que estás leyendo. Recursos para agentes: - **`https://app.txncompany.com/llms.txt`** — índice corto (convención [llms.txt](https://llmstxt.org)) con los enlaces esenciales. - **`https://app.txncompany.com/llms-full.txt`** — esta guía completa en Markdown plano, con las URLs y claves de **producción** resueltas y un encabezado con los equivalentes de sandbox. - **`https://api.txncompany.com/api/v1/openapi.json`** — especificación **OpenAPI** de la API v1 (esquemas exactos de request/response). Copia y pega este prompt en tu asistente de IA para que arme la integración de punta a punta en **sandbox**: ```text Integra la API de pagos de TX Pay (Hosted Checkout) en este proyecto. 1. Lee primero la documentación completa en https://app.txncompany.com/llms-full.txt y la especificación OpenAPI en https://api.txncompany.com/api/v1/openapi.json. 2. Usa el entorno SANDBOX: base URL https://api-dev.txncompany.com y una secret key de prueba con prefijo sk_test_ (léela desde la variable de entorno TXPAY_SECRET_KEY; nunca la escribas en el código ni la expongas en el frontend). 3. Implementa en el servidor: a) POST /api/v1/checkout-sessions con amount (CLP, entero), currency "CLP", external_reference (mi ID de orden), success_url y cancel_url, enviando un header Idempotency-Key único por orden. b) Redirección del comprador a la checkout_url que devuelve la API. c) Un endpoint de retorno que lea ?session_id= y confirme el estado real con GET /api/v1/checkout-sessions/:id (no confiar solo en la redirección). d) Un endpoint público HTTPS que reciba los webhooks: verifica el header X-TXPay-Signature (t=,v1=) calculando HMAC-SHA256 de "." con el webhook secret (variable TXPAY_WEBHOOK_SECRET), compara en tiempo constante, rechaza timestamps fuera de ±300 s y responde 2xx en menos de 10 segundos. 4. Al terminar, verifica este checklist y dime qué hiciste en cada punto: - Idempotency-Key único por orden y manejo de 409 idempotency_key_reused. - Firma del webhook verificada sobre el body crudo (sin re-serializar). - Deduplicación de eventos por su id (los reenvíos reusan el mismo id). - Un checkout_session.completed puede llegar DESPUÉS de un checkout_session.failed para la misma sesión: el estado final lo define el último evento confirmado con el GET. - Manejo de failed (rejected/voided/refunded) y expired además de approved. - Para pasar a producción solo cambian la base URL (https://api.txncompany.com) y la clave (sk_live_...): déjalos configurables por entorno. ``` ## Introducción y conceptos El flujo de un pago es siempre el mismo: 1. **Creas la sesión** desde tu servidor (`POST /api/v1/checkout-sessions`) con el monto y las URLs de retorno. 2. **Rediriges al comprador** a la `checkout_url` que te devolvemos. 3. **El comprador paga** en la página/proveedor (Webpay Plus, Vita Wallet, etc.). 4. **Vuelve a tu sitio** (`success_url` o `cancel_url`) y/o recibes un webhook. 5. **Verificas el estado real** con `GET /api/v1/checkout-sessions/:id` (la fuente de verdad). - **URL base (sandbox / pruebas):** `https://api-dev.txncompany.com` - **URL base (producción):** `https://api.txncompany.com` - **Versión:** todas las rutas cuelgan de `/api/v1`. - **Referencia interactiva (OpenAPI):** `GET /api/v1/docs` · especificación en JSON: `GET /api/v1/openapi.json` > El entorno de desarrollo (`api-dev.txncompany.com`) es **sandbox**: no mueve plata real. Úsalo para integrar y probar antes de pasar a producción. ## Autenticación **No hay endpoint de login ni tokens de sesión**: la secret key es la credencial y viaja en el header `Authorization` de **cada** request. Cada request se autentica con tu **secret key** en el header `Authorization`: ```bash curl https://api.txncompany.com/api/v1/checkout-sessions \ -H "Authorization: Bearer sk_live_TU_SECRET" \ -H "Content-Type: application/json" \ -d '{"amount":19990,"currency":"CLP", "success_url":"https://tu-tienda.cl/pago/ok", "cancel_url":"https://tu-tienda.cl/pago/cancelado"}' ``` ```js const res = await fetch('https://api.txncompany.com/api/v1/checkout-sessions', { method: 'POST', headers: { Authorization: 'Bearer sk_live_TU_SECRET', 'Content-Type': 'application/json', }, body: JSON.stringify({ amount: 19990, currency: 'CLP', success_url: 'https://tu-tienda.cl/pago/ok', cancel_url: 'https://tu-tienda.cl/pago/cancelado', }), }); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer sk_live_TU_SECRET', 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 19990, 'currency' => 'CLP', 'success_url' => 'https://tu-tienda.cl/pago/ok', 'cancel_url' => 'https://tu-tienda.cl/pago/cancelado', ]), ]); $response = curl_exec($ch); ``` ```python import requests resp = requests.post( "https://api.txncompany.com/api/v1/checkout-sessions", headers={ "Authorization": "Bearer sk_live_TU_SECRET", "Content-Type": "application/json", }, json={ "amount": 19990, "currency": "CLP", "success_url": "https://tu-tienda.cl/pago/ok", "cancel_url": "https://tu-tienda.cl/pago/cancelado", }, ) ``` En **producción** las claves llevan el prefijo `sk_live_…`/`pk_live_…`; en **sandbox** (`api-dev.txncompany.com`) llevan `sk_test_…`/`pk_test_…`. El entorno queda definido por la base URL a la que le pegas, no por la clave. (Claves de sandbox generadas antes de jul 2026 conservan el prefijo `sk_live_…` hasta que las rotes; funcionan igual — la autenticación no depende del prefijo.) Sin una key válida (faltante, inválida o de un comercio **inactivo**), cualquier endpoint responde `401`: ```json { "error": { "type": "authentication_error", "code": "invalid_api_key", "message": "Invalid or inactive API key.", "request_id": "a1b2c3d4-1234-4a1b-9abc-1234567890ab" } } ``` Obtén y rota tus claves desde el dashboard → detalle del comercio → **Desarrolladores**. El secret se muestra **una sola vez** al generarlo o rotarlo; guárdalo de forma segura. > La `public_key` (`pk_live_…`/`pk_test_…`) que ves en el dashboard es solo un identificador visible de tu comercio: **no se envía en ningún request de la API v1**. > Nunca expongas tu **secret key** en el frontend ni en repositorios. Si se filtra, rótala de inmediato desde el dashboard. ## Quickstart ### Paso 1 — Crear la checkout session (tu servidor) ```bash curl -X POST https://api.txncompany.com/api/v1/checkout-sessions \ -H "Authorization: Bearer sk_live_TU_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 4f1a-orden-4821" \ -d '{ "amount": 19990, "currency": "CLP", "external_reference": "order-4821", "customer_email": "ana@correo.cl", "metadata": { "order_id": "4821" }, "success_url": "https://tu-tienda.cl/pago/ok", "cancel_url": "https://tu-tienda.cl/pago/cancelado" }' ``` ```js const res = await fetch('https://api.txncompany.com/api/v1/checkout-sessions', { method: 'POST', headers: { Authorization: 'Bearer sk_live_TU_SECRET', 'Content-Type': 'application/json', 'Idempotency-Key': '4f1a-orden-4821', }, body: JSON.stringify({ amount: 19990, currency: 'CLP', external_reference: 'order-4821', customer_email: 'ana@correo.cl', metadata: { order_id: '4821' }, success_url: 'https://tu-tienda.cl/pago/ok', cancel_url: 'https://tu-tienda.cl/pago/cancelado', }), }); const session = await res.json(); // session.checkout_url -> redirige al comprador ``` ```php 19990, 'currency' => 'CLP', 'external_reference' => 'order-4821', 'customer_email' => 'ana@correo.cl', 'metadata' => ['order_id' => '4821'], 'success_url' => 'https://tu-tienda.cl/pago/ok', 'cancel_url' => 'https://tu-tienda.cl/pago/cancelado', ]; $ch = curl_init('https://api.txncompany.com/api/v1/checkout-sessions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer sk_live_TU_SECRET', 'Content-Type: application/json', 'Idempotency-Key: 4f1a-orden-4821', ], CURLOPT_POSTFIELDS => json_encode($payload), ]); $session = json_decode(curl_exec($ch), true); // $session['checkout_url'] -> redirige al comprador ``` ```python import requests resp = requests.post( "https://api.txncompany.com/api/v1/checkout-sessions", headers={ "Authorization": "Bearer sk_live_TU_SECRET", "Content-Type": "application/json", "Idempotency-Key": "4f1a-orden-4821", }, json={ "amount": 19990, "currency": "CLP", "external_reference": "order-4821", "customer_email": "ana@correo.cl", "metadata": {"order_id": "4821"}, "success_url": "https://tu-tienda.cl/pago/ok", "cancel_url": "https://tu-tienda.cl/pago/cancelado", }, ) session = resp.json() # session["checkout_url"] -> redirige al comprador ``` Respuesta `201`: ```json { "id": "cs_66a1e4f2b8c9a1234567890a", "object": "checkout_session", "status": "pending", "amount": 19990, "currency": "CLP", "external_reference": "order-4821", "checkout_url": "https://api.txncompany.com/checkout/66a1e4f2b8c9a1234567890a", "expires_at": "2026-06-26T12:30:00Z", "created_at": "2026-06-26T12:15:00Z", "livemode": true, "metadata": { "order_id": "4821" }, "payment_method": null, "customer_email": "ana@correo.cl", "source_country": null, "failure_reason": null, "net_amount": null, "fee_amount": null } ``` > `metadata` solo aparece en la respuesta cuando la mandaste (objeto no vacío). `source_country` es `null` si no lo enviaste (si lo enviaste, se devuelve en minúsculas). `payment_method`, `net_amount` y `fee_amount` quedan en `null` hasta que hay un resultado: `payment_method` se completa apenas el comprador elige método; `net_amount`/`fee_amount` solo se completan cuando `status` pasa a `approved`. ### Paso 2 — Redirigir al comprador Redirige el navegador del comprador a la `checkout_url` que recibiste. ### Paso 3 — El comprador vuelve En ambas URLs de retorno agregamos `?session_id=cs_…` como query param. A dónde vuelve depende del resultado: - **`success_url`** — solo cuando el pago quedó **`approved`**. - **`cancel_url`** — cuando la sesión terminó `failed` (`rejected` o `voided`) o `expired`. - **Sin redirección mientras el pago sigue pendiente** — por ejemplo, una transferencia bancaria vía Vita que el banco todavía no confirmó. El comprador se queda en la página de resultado de TX Pay y **no** vuelve a tu sitio: para enterarte del desenlace consulta el `GET` periódicamente o espera el webhook. ⚠️ **No confíes en la redirección**: verifica el estado real (paso 4) o espera el webhook. ### Paso 4 — Verificar el estado (fuente de verdad) ```bash curl https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a \ -H "Authorization: Bearer sk_live_TU_SECRET" ``` ```js const res = await fetch( 'https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a', { headers: { Authorization: 'Bearer sk_live_TU_SECRET' } }, ); const session = await res.json(); // session.status -> "approved" | "failed" | "expired" | "pending" ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_TU_SECRET'], ]); $session = json_decode(curl_exec($ch), true); // $session['status'] ``` ```python import requests resp = requests.get( "https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a", headers={"Authorization": "Bearer sk_live_TU_SECRET"}, ) session = resp.json() # session["status"] ``` ### Ciclo de vida de la sesión 1. **Creas** la sesión (`POST`) → nace en `pending`, con `expires_at` a **15 minutos** de la creación (por defecto; el plazo es configurable por comercio). 2. El comprador **paga o abandona** en `checkout_url`: - Pago aprobado → `approved`. - Rechazado por el proveedor, o el comprador abortó el pago en Webpay → `failed` (`failure_reason`: `"rejected"`). - No hace nada dentro del plazo → `expired` (`failure_reason`: `"expired"`). 3. Llega al **estado final** → el comprador vuelve a tu `success_url` (solo `approved`) o `cancel_url` (`failed`/`expired`) con `session_id` y, si configuraste una webhook URL, te enviamos el evento correspondiente. 4. **Confirmas** con `GET /api/v1/checkout-sessions/:id` — la redirección y el webhook son señales; el `GET` es la fuente de verdad. 5. **Reversiones posteriores:** si el comercio **anula** (`voided`) o **reembolsa** (`refunded`) la orden desde el panel después de aprobada, la sesión pasa a `failed` con ese `failure_reason` y te enviamos `checkout_session.failed`. Como ya pudiste haber recibido `checkout_session.completed`, trata un `failed` posterior como una **reversión**. > También puedes cerrar una sesión tú antes de que expire con `POST /api/v1/checkout-sessions/:id/expire` (por ejemplo si el comprador cancela la orden en tu sitio antes de pagar). Dispara el mismo webhook `checkout_session.failed` que una expiración automática. ## Referencia ### `POST /api/v1/checkout-sessions` Crea una sesión de pago. Campos del body: | Campo | Tipo | Req | Notas | |---|---|---|---| | `amount` | entero | sí | CLP, pesos enteros. Es el **total que paga el cliente** (bruto); recibes este monto **menos las comisiones**. Mayor a 0. | | `currency` | string | sí | `"CLP"`. Es la moneda en la que liquidas (siempre CLP). | | `success_url` | string (URL) | sí | Retorno al aprobar. En **producción** debe ser `https`; en **sandbox** también se aceptan `http://` y `localhost` para probar en local. | | `cancel_url` | string (URL) | sí | Retorno al cancelar/fallar. Mismas reglas que `success_url`. | | `external_reference` | string | no | ≤255 caracteres. Tu ID de orden; te lo devolvemos. | | `customer_email` | string | no | Se valida como email. Se guarda y se devuelve en la sesión; no autocompleta el checkout ni implica el envío de un recibo. | | `source_country` | string | no | Solo Vita (cross-border): país del comprador (ISO 3166-1 alpha-2, mayúsculas o minúsculas; ej. `AR`, `BR`, `MX`, `CO`). Códigos soportados: `CL CO AR MX VE US ES EU PE PA CN GB BR EC` (`EU` es un pseudo-código propio de Vita para la eurozona, distinto de `ES`). La moneda en la que paga el comprador se **deriva** de este país. Si no lo envías, la sesión devuelve `null`; si lo envías, te lo devolvemos **en minúsculas**. | | `metadata` | objeto plano | no | Debe ser un **objeto** (no array ni string). Máx **20 claves**; cada valor debe ser **string de hasta 500 caracteres** (si no, `400`). Te lo devolvemos tal cual en la sesión y en el webhook. | > Los campos que no están en esta tabla se **ignoran en silencio** (no producen error). En particular, `source_currency` **ya no se usa**: la moneda del comprador se deriva de `source_country`. > Tu comercio puede tener un **monto mínimo y/o máximo** configurado. Si `amount` queda fuera de ese rango, la API responde `400` con `amount_below_minimum` o `amount_above_maximum`. #### Cómo se trabajan los montos - **`amount` es bruto**, siempre en CLP: es el total que **paga el cliente**. Recibes `amount − comisiones` (comisión de TX Pay + IVA sobre esa comisión). Ejemplo: con `amount: 20000` y una comisión efectiva de ~3,5% + IVA, el cliente paga $20.000 y liquidas ≈ $19.167. - En la sesión aprobada, `net_amount` es lo que liquidas y `fee_amount = amount − net_amount` (comisión de TX Pay **con su IVA incluido**). Ambos solo aparecen cuando `status` es `approved`. - **Cross-border (Vita):** si envías un `source_country` distinto de Chile, el comprador paga el **equivalente en su moneda local** (ej. ARS). La conversión la calculamos nosotros con la tasa vigente de Vita; el comprador **no** paga el spread de cambio (lo absorbe TX Pay). Tú sigues liquidando en **CLP**. - En todos los casos `currency` es `CLP`: define la moneda del `amount` y de tu liquidación, no la del comprador. **Idempotencia:** manda un header `Idempotency-Key` único por orden. Ver [Idempotencia](#idempotencia) para el detalle completo (reintentos, conflictos y TTL). ### `GET /api/v1/checkout-sessions/:id` Devuelve la sesión con su `status` actual. Solo puedes consultar **tus** sesiones. ### `GET /api/v1/checkout-sessions` Lista tus sesiones, más recientes primero. | Query param | Tipo | Notas | |---|---|---| | `page` | entero | default `1` | | `limit` | entero | default `10`, máx `100` | | `status` | string | `pending` \| `approved` \| `failed` \| `expired` | | `external_reference` | string | filtra por tu ID de orden (**coincidencia exacta**, sensible a mayúsculas) | ```bash curl "https://api.txncompany.com/api/v1/checkout-sessions?status=approved&limit=10&page=1" \ -H "Authorization: Bearer sk_live_TU_SECRET" ``` ```js const res = await fetch( 'https://api.txncompany.com/api/v1/checkout-sessions?status=approved&limit=10&page=1', { headers: { Authorization: 'Bearer sk_live_TU_SECRET' } }, ); const list = await res.json(); // list.data -> array de checkout sessions ``` ```php true, CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_TU_SECRET'], ]); $list = json_decode(curl_exec($ch), true); // $list['data'] ``` ```python import requests resp = requests.get( "https://api.txncompany.com/api/v1/checkout-sessions", headers={"Authorization": "Bearer sk_live_TU_SECRET"}, params={"status": "approved", "limit": 10, "page": 1}, ) list_ = resp.json() # list_["data"] ``` ```json { "object": "list", "data": [ { "id": "cs_...", "...": "..." } ], "page": 1, "limit": 10, "has_more": false } ``` ### `POST /api/v1/checkout-sessions/:id/expire` Cierra manualmente una sesión que el comprador **todavía no empezó a pagar**. Responde `200` con la sesión ya en `status: "expired"`. Si el comprador **ya eligió un método de pago** (la sesión está en curso) o la sesión ya llegó a un estado final, responde `400` con `code: "session_not_expirable"`. ```bash curl -X POST https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a/expire \ -H "Authorization: Bearer sk_live_TU_SECRET" ``` ```js const res = await fetch( 'https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a/expire', { method: 'POST', headers: { Authorization: 'Bearer sk_live_TU_SECRET' } }, ); const session = await res.json(); // res.status -> 200 · session.status -> "expired" ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer sk_live_TU_SECRET'], ]); $session = json_decode(curl_exec($ch), true); // $session['status'] -> "expired" ``` ```python import requests resp = requests.post( "https://api.txncompany.com/api/v1/checkout-sessions/cs_66a1e4f2b8c9a1234567890a/expire", headers={"Authorization": "Bearer sk_live_TU_SECRET"}, ) session = resp.json() # resp.status_code -> 200 · session["status"] -> "expired" ``` ### Estados | Estado | Significado | |---|---| | `pending` | creada, aún sin pago final | | `approved` | pago aprobado | | `failed` | rechazado, abandonado, anulado o reembolsado (`failure_reason`: `"rejected"` \| `"voided"` \| `"refunded"`) | | `expired` | expiró sin pagarse (`failure_reason`: `"expired"`) | Detalle de `failure_reason` cuando el estado es `failed`: | `failure_reason` | Cuándo ocurre | |---|---| | `rejected` | el proveedor rechazó el pago, **o** el comprador abortó el pago en Webpay | | `voided` | el comercio **anuló** la orden desde el panel (después de aprobada) | | `refunded` | el comercio **reembolsó** la orden desde el panel (después de aprobada) | Tanto `voided` como `refunded` llegan como `status: "failed"` y disparan el webhook `checkout_session.failed`, así que si ya habías procesado un `checkout_session.completed` para esa sesión, trata el `failed` posterior como una **reversión**. Para el detalle completo de cada campo y esquema, revisa la referencia interactiva OpenAPI en `GET /api/v1/docs` (o la especificación en `GET /api/v1/openapi.json`). ## Webhooks Si configuras una **webhook URL** en el dashboard (sección Desarrolladores), te enviamos un `POST` firmado cuando la sesión llega a un estado final — tanto por acción del comprador como por una expiración automática o manual (`POST /:id/expire`), o por una anulación/reembolso desde el panel. > **Configura la webhook URL antes de crear sesiones.** La URL se **congela en cada sesión al momento de crearla**: si la cambias después, las sesiones ya creadas siguen notificando a la URL anterior; solo las nuevas usan la nueva. **Eventos:** `checkout_session.completed` (aprobado) · `checkout_session.failed` (rechazado/abandonado/expirado, y también anulado o reembolsado desde el panel — `failure_reason`: `voided` | `refunded`). **Headers + body:** ```http X-TXPay-Event: checkout_session.completed X-TXPay-Signature: t=1782373200,v1=4f8a... { "id": "evt_...", "type": "checkout_session.completed", "livemode": true, "created_at": "2026-06-26T12:28:00Z", "data": { "id": "cs_...", "status": "approved", "amount": 19990, "currency": "CLP", "external_reference": "order-4821", "metadata": { "order_id": "4821" }, "payment_method": "webpay", "customer_email": "ana@correo.cl", "source_country": null, "failure_reason": null, "net_amount": 19167, "fee_amount": 823 } } ``` > `data` es la misma vista que te devuelve el `GET`, salvo `checkout_url`, `expires_at`, `created_at` y `object` (no aportan al evento) y `livemode` (ya va en el nivel superior). `metadata` aparece solo si la mandaste. ### Verificar la firma La firma viaja en `X-TXPay-Signature` como `t=,v1=`. Calcula un HMAC-SHA256 de `"."` con tu **webhook secret** y compáralo en tiempo constante. Rechaza si el timestamp está fuera de la tolerancia (anti-replay): ```js const crypto = require('crypto'); function verify(rawBody, header, secret, toleranceSec = 300) { try { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; // anti-replay const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); const a = Buffer.from(parts.v1 || '', 'hex'); const b = Buffer.from(expected, 'hex'); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } catch { return false; } } ``` ```php $tolerance) return false; // anti-replay $expected = hash_hmac('sha256', ($p['t'] ?? '') . '.' . $rawBody, $secret); return hash_equals($expected, $p['v1'] ?? ''); } ``` ```python import hashlib import hmac import time def verify(raw_body: str, header: str, secret: str, tolerance: int = 300) -> bool: try: parts = dict(p.split("=", 1) for p in header.split(",")) ts = int(parts["t"]) if abs(time.time() - ts) > tolerance: # anti-replay return False expected = hmac.new( secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, parts.get("v1", "")) except Exception: return False ``` > En curl no hay verificación de firma: es un cómputo que ocurre en tu servidor al recibir el webhook. Usa el snippet de tu lenguaje (Node, PHP o Python). Calcula el HMAC sobre el **body crudo** tal como llegó (no lo parsees y re-serialices). ### Entrega y reintentos - **Primer intento dentro de los primeros ~45 segundos** desde que la sesión llega al estado final (un worker despacha las entregas pendientes cada 45 s). - Entrega **at-least-once** con reintentos con backoff: **1 m, 5 m, 30 m, 2 h y 6 h** después del primer intento (6 intentos en total, ventana de **~8 h 36 m**). Tras el 6º intento fallido, la entrega queda marcada `failed` y no se reintenta más. - Tu endpoint debe responder **`2xx` en menos de 10 segundos**. Haz el procesamiento pesado de forma asíncrona y responde de inmediato. - Solo se reintentan **`5xx`, `408`, `429` y timeouts**. Cualquier otro `4xx` marca la entrega como fallida de forma definitiva (revisa tu endpoint). - **No seguimos redirecciones**: un `3xx` cuenta como fallo **permanente**. Apunta la webhook URL directamente al destino final (sin `www` → apex, `http` → `https`, etc.). - **`https` obligatorio en producción** (rechazamos hosts internos/privados). En **sandbox** también aceptamos `http://` para probar en local. - **Deduplica por el `id` del evento.** El botón **"Reenviar"** del panel de *Entregas de webhooks* reutiliza el **mismo `id`**, así que un reenvío manual no debe procesarse dos veces. - Un `checkout_session.completed` puede llegar **después** de un `checkout_session.failed` para la misma sesión (por ejemplo, una aprobación tardía del proveedor cuando la sesión ya había expirado localmente). Confirma siempre el estado con el `GET` y deja que el último estado confirmado gane. - El webhook es una conveniencia: la **fuente de verdad** siempre es el `GET`. ## Errores Toda respuesta bajo `/api/v1/` incluye el header `X-Request-Id` (un UUID). Si nos escribes por un problema, cítalo — también viaja como `error.request_id` en las respuestas de error. Si envías tu propio `X-Correlation-ID` (`[A-Za-z0-9-]{1,64}`), lo reutilizamos como request id: lo verás en `X-Request-Id` y en `error.request_id`, lo que te permite correlacionar tus logs con los nuestros. Formato uniforme de error (también para rutas inexistentes bajo `/api/v1/`, que responden `404` con este mismo sobre): ```json { "error": { "type": "invalid_request", "code": "amount_below_minimum", "message": "amount must be at least 50 CLP", "param": "amount", "request_id": "a1b2c3d4-1234-4a1b-9abc-1234567890ab" } } ``` Cualquier error de **validación de un campo** del body o de la query (tipo incorrecto, URL inválida, email malformado, etc.) llega como `code: "invalid_request"`, con un `message` legible y `param` = el campo que falló. Los códigos específicos son: | HTTP | type | code | Cuándo | |---|---|---|---| | 400 | `invalid_request` | `invalid_request` | validación genérica de un campo (`param` indica cuál) | | 400 | `invalid_request` | `amount_below_minimum` | `amount` menor al mínimo configurado para tu comercio | | 400 | `invalid_request` | `amount_above_maximum` | `amount` mayor al máximo configurado para tu comercio | | 400 | `invalid_request` | `metadata_too_many_keys` | `metadata` con más de 20 claves | | 400 | `invalid_request` | `metadata_invalid_value` | un valor de `metadata` no es string | | 400 | `invalid_request` | `metadata_value_too_long` | un valor de `metadata` supera los 500 caracteres | | 401 | `authentication_error` | `invalid_api_key` | API key faltante/inválida, o **merchant inactivo** | | 403 | `permission_error` | `no_payment_methods` | el comercio **no tiene métodos de pago habilitados** para checkout (Webpay Plus/Vita) | | 404 | `not_found` | `resource_missing` | sesión inexistente o ajena; también rutas inexistentes bajo `/api/v1/` | | 400 | `invalid_request` | `session_not_expirable` | `POST /:id/expire` sobre una sesión en curso (el comprador ya eligió método) o ya finalizada | | 409 | `idempotency_error` | `idempotency_key_reused` | misma `Idempotency-Key` con body distinto — no reintentes | | 409 | `idempotency_error` | `idempotency_key_in_progress` | misma `Idempotency-Key`, request anterior todavía en curso — reintenta en unos segundos | | 429 | `rate_limit_error` | `rate_limited` | superaste el rate limit | | 500 | `api_error` | `internal_error` | error inesperado de nuestro lado; es seguro reintentar con la misma `Idempotency-Key` | ### Rate limiting - **120 requests por minuto por credencial y por endpoint** (cada endpoint tiene su propio cupo). Los requests **sin** header `Authorization` se limitan **por IP**. - Toda respuesta de los endpoints de `checkout-sessions` incluye `X-RateLimit-Limit` (cupo por ventana), `X-RateLimit-Remaining` (requests que te quedan) y `X-RateLimit-Reset` (segundos hasta que se renueva la ventana). - Al superarlo, la respuesta `429` incluye además el header `Retry-After` (segundos) para saber cuándo reintentar. ## Idempotencia Para evitar cobros duplicados ante reintentos de red, envía un header `Idempotency-Key` único por orden al crear la sesión: ```bash curl -X POST https://api.txncompany.com/api/v1/checkout-sessions \ -H "Authorization: Bearer sk_live_TU_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4821" \ -d '{"amount":19990,"currency":"CLP", "success_url":"https://tu-tienda.cl/ok", "cancel_url":"https://tu-tienda.cl/no"}' ``` ```js await fetch('https://api.txncompany.com/api/v1/checkout-sessions', { method: 'POST', headers: { Authorization: 'Bearer sk_live_TU_SECRET', 'Content-Type': 'application/json', 'Idempotency-Key': 'order-4821', }, body: JSON.stringify({ amount: 19990, currency: 'CLP', success_url: 'https://tu-tienda.cl/ok', cancel_url: 'https://tu-tienda.cl/no', }), }); ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer sk_live_TU_SECRET', 'Content-Type: application/json', 'Idempotency-Key: order-4821', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 19990, 'currency' => 'CLP', 'success_url' => 'https://tu-tienda.cl/ok', 'cancel_url' => 'https://tu-tienda.cl/no', ]), ]); $session = json_decode(curl_exec($ch), true); ``` ```python import requests resp = requests.post( "https://api.txncompany.com/api/v1/checkout-sessions", headers={ "Authorization": "Bearer sk_live_TU_SECRET", "Content-Type": "application/json", "Idempotency-Key": "order-4821", }, json={ "amount": 19990, "currency": "CLP", "success_url": "https://tu-tienda.cl/ok", "cancel_url": "https://tu-tienda.cl/no", }, ) session = resp.json() # resp.headers.get("Idempotency-Replayed") -> "true" si fue un replay ``` - Reintento con la **misma clave y el mismo body** → devuelve la **misma** sesión (no duplica) y agrega el header `Idempotency-Replayed: true` a la respuesta. - Misma clave con un body **distinto** → `409` con `code: "idempotency_key_reused"` (no reintentes; usa otra clave). - Dos requests **concurrentes** con la misma clave → el que llega segundo puede recibir `409` con `code: "idempotency_key_in_progress"` (el primero ya está procesando; reintenta en unos segundos). - La clave **vive 24 h**: pasado ese plazo, reutilizarla crea una sesión nueva. - La clave es **por comercio**: dos comercios distintos pueden usar la misma clave sin interferir. - Las validaciones de la **configuración de tu comercio** (API key válida, comercio activo, métodos de pago habilitados, límites de monto y validación de `metadata`) corren **antes** del replay: si tu comercio quedó inactivo o sin métodos de pago, recibirás ese error en vez de la sesión repetida. - Usa un identificador estable de tu orden (por ejemplo el ID interno del carrito). ## Datos de prueba (Sandbox) > El entorno de desarrollo (`api-dev.txncompany.com`) es **sandbox**: las transacciones no mueven plata real. Úsalo para validar tu integración de punta a punta. Los datos de prueba pertenecen a los proveedores y pueden cambiar sin aviso. **Última verificación: 2026-09-27.** Ante cualquier duda, la fuente autorizada es siempre la documentación oficial enlazada en cada sección. ### Webpay Plus (Transbank) En sandbox, Webpay Plus redirige al ambiente de integración de Transbank. Usa las **tarjetas de prueba oficiales**: | Tarjeta | Número | Resultado | |---|---|---| | VISA (crédito) | `4051 8856 0044 6623` | Aprueba | | MASTERCARD (crédito) | `5186 0595 5959 0568` | Rechaza | | Datos de autenticación | CVV `123`, fecha cualquiera futura | — | En la pantalla del banco simulado (RUT `11.111.111-1`, clave `123`) confirmas o rechazas el pago. - Tarjetas y guía oficial: [Transbank Developers — Tarjetas de prueba](https://www.transbankdevelopers.cl/documentacion/como_empezar#tarjetas-de-prueba) ### Fintoc (riel de Vita Wallet) Fintoc es un riel de pago por transferencia bancaria disponible vía Vita Wallet. En el ambiente de pruebas, su **simulador bancario** te permite completar el flujo sin una cuenta real: seleccionas el banco de prueba, ingresas las credenciales del simulador y apruebas la transferencia. - Guía oficial de pruebas: [Fintoc Docs — Test your integration](https://docs.fintoc.com/docs/payment-initiation-test-your-integration) ## Checklist para producción 1. Verificas el estado con el `GET` (no confías en la redirección). 2. Si usas webhooks, configuraste la URL **antes** de crear sesiones, validas la firma sobre el body crudo, respondes `2xx` en menos de 10 s y deduplicas por `id`. 3. Manejas los estados `failed` (`rejected`/`voided`/`refunded`) y `expired` además de `approved`, incluido un `completed` que llegue después de un `failed`. 4. Envías `Idempotency-Key` por orden y manejas el `409 idempotency_key_reused`. 5. Guardas tu secret key de forma segura y la rotas si se filtra. 6. Cambias la URL base a `https://api.txncompany.com` y usas tus claves de producción (`sk_live_…`/`pk_live_…`) en lugar de las de sandbox (`sk_test_…`/`pk_test_…`). 7. Pruebas el flujo completo en sandbox antes de habilitar producción.