# Webhooks del API Bancario

> Registro de destino, features suscribibles y contratos de payload de los webhooks del API Bancario.

- kind: api-operation
- status: stable
- api_version: 1.0.0
- last_verified: 2026-09-02
- url: https://www.tilopay.com/developers/api-bancario/webhooks

El grupo `Webhooks` permite registrar el destino HTTPS donde recibís los eventos, ver qué
features podés suscribir por método de pago y consultar el contrato de payload de cada
evento antes de escribir el receptor.

```http
POST /api/public/v1/webhook/destination
GET  /api/public/v1/webhook/features
GET  /api/public/v1/webhook/event-contracts/{event_type}
```

## Eventos [#eventos]

El spec declara cuatro tipos de evento:

| Evento | Cuándo llega |
|---|---|
| `payin.posted` | Se acreditó un movimiento de entrada. |
| `payment.succeeded` | El pago terminó de forma exitosa. |
| `payment.failed` | El pago terminó con falla. |
| `payment.reversed` | El pago fue revertido. |

Un pago revertido conserva el estado `posted` y se distingue por `result: reversed` y
`has_reversal: true`; ver [estados del pago](/developers/api-bancario/pagos#estados).

## Buenas prácticas del receptor [#receptor]

Respondé `2xx` en cuanto recibís el evento y procesá aparte: un receptor lento provoca
reenvíos. Tratá los eventos como potencialmente repetidos y deduplicá por el identificador
del pago. Y no confíes el estado final sólo al webhook: confirmalo con
[la consulta del pago](/developers/api-bancario/pagos#operaciones).

## Operaciones [#operaciones]

### Create webhook destination [#post-webhook-destination]

```http
POST /api/public/v1/webhook/destination
```

Host: `https://api-baas-sandbox.tilopay.com` — requiere `Authorization: Bearer <access_token>`.

Valid fields depend on `destination_kind` and `scope_kind`. Send only the fields that apply to your combination; other scope fields must be omitted (not null placeholders).

**Event payloads**
Inspect outbound contracts with
`GET /api/public/v1/webhook/event-contracts/{event_type}`
(`payin.posted`, `payment.succeeded`, `payment.failed`, `payment.reversed`).

**destination_kind**
- `EXTERNAL_WEBHOOK` (only kind available on the public API): requires `webhook_url` (must start with `https://`) and `secret_ref`. Optional delivery tuning (`timeout_ms`, `max_attempts`). Payloads are always encrypted with `AES_GCM` using `secret_ref`; the HTTP body is `{"data":"<ciphertext>"}`.
- `INTERNAL` is reserved for platform administration and cannot be created through this endpoint.

**scope_kind**
- `TENANT`: omit `scope_account_id`, `scope_owner_type`, and `scope_owner_id`.
- `ACCOUNT`: require `scope_account_id`; omit owner fields.
- `OWNER`: require `scope_owner_type` and `scope_owner_id`; omit `scope_account_id`. `scope_owner_type` is one of `tenant`, `partner`, `user`, `platform`, `customer` (lowercase in payload).

## Webhook encryption (EXTERNAL_WEBHOOK)

Deliveries always send `Content-Type: application/json` and `Accept: application/json`.

The HTTP body is always `{"data": "<base64>"}`. The base64-decoded bytes are:

```
nonce (12 bytes, random) || AES-256-GCM ciphertext || 16-byte auth tag
```

```
key                   = SHA256(secret_ref)              # 32 bytes → AES-256
wire                  = base64_decode(data)
nonce, sealed         = wire[:12], wire[12:]             # sealed = ciphertext || tag
plaintext             = AES-GCM-Open(key, nonce, sealed, additional_data = none)
```

No additional authenticated data (AAD) is used. `plaintext` is the same JSON
envelope documented per event type in
`GET /webhook/event-contracts/{event_type}` — decrypt first, then apply that
contract. Authenticity is provided by the GCM auth tag (decryption fails if the
body was tampered with).

Deduplicate retries using `event.event_id` in the payload envelope — delivery
retries can redeliver the same event with the same `event_id`.

**Parámetros**

| Parámetro | En | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | — | Optional client-supplied correlation id for end-to-end tracing. Echoed back as `correlation_id` in the response envelope. If omitted, the API generates one and still returns it. |

**Cuerpo del request**

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `feature_id` | string (uuid) | sí | Feature to subscribe (from `GET /api/public/v1/webhook/features`). Must exist or create fails. |
| `is_enabled` | boolean | — | Defaults to true if omitted. |
| `destination_kind` | string | sí | Public API accepts only `EXTERNAL_WEBHOOK` (HTTPS callback with shared secret). `INTERNAL` destinations are managed administratively. Values: EXTERNAL_WEBHOOK |
| `destination_ref` | string | — | Not used on the public API. Reserved for internal destinations. |
| `webhook_url` | string (uri) | — | Required (must use `https://`). |
| `secret_ref` | string | — | Required shared secret used to decrypt AES_GCM payloads (`key = SHA256(secret_ref)`). See "Webhook encryption" in this operation's description. |
| `signature_algo` | string | — | Fixed to `AES_GCM` for `EXTERNAL_WEBHOOK`. Omitted on create; stored as `AES_GCM`. `HMAC_SHA256` is no longer supported. Values: AES_GCM |
| `replay_window_sec` | integer | — | Reserved for legacy destinations. Not used for `EXTERNAL_WEBHOOK` AES_GCM deliveries. |
| `timeout_ms` | integer | — | HTTP timeout for delivery attempts (`EXTERNAL_WEBHOOK`). |
| `max_attempts` | integer | — | Max delivery attempts (`EXTERNAL_WEBHOOK`). |
| `backoff_policy` | object | — | Optional free-form JSON retry/backoff override, stored as-is and not currently validated or documented by the platform. Omit to use the platform's default backoff policy. |
| `scope_kind` | string | sí | - `TENANT`: no scope id fields. - `ACCOUNT`: set `scope_account_id` only. - `OWNER`: set `scope_owner_type` and `scope_owner_id` only. Values: TENANT, ACCOUNT, OWNER |
| `scope_account_id` | string (uuid) | — | Required when `scope_kind` is `ACCOUNT`. Must be omitted for `TENANT` and `OWNER`. |
| `scope_owner_type` | string | — | Required when `scope_kind` is `OWNER`. Allowed values (lowercase) `tenant`, `partner`, `user`, `platform`, `customer`. Omit for `TENANT` and `ACCOUNT`. |
| `scope_owner_id` | string (uuid) | — | Required when `scope_kind` is `OWNER`. Omit for `TENANT` and `ACCOUNT`. |

**Ejemplo de request** — external_webhook_tenant

```json
{
  "feature_id": "11111111-1111-1111-1111-111111111111",
  "destination_kind": "EXTERNAL_WEBHOOK",
  "scope_kind": "TENANT"
}
```

**201** — The resource was created successfully.

`response_code`: `CREATED`

Campos de `data`:

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `created_at` | string | — | — |
| `destination_kind` | string | sí | — |
| `destination_ref` | string | — | — |
| `feature_id` | string | sí | — |
| `id` | string | sí | — |
| `is_enabled` | boolean | sí | — |
| `max_attempts` | integer | — | — |
| `replay_window_sec` | integer | sí | — |
| `scope_account_id` | string | — | — |
| `scope_kind` | string | sí | — |
| `scope_owner_id` | string | — | — |
| `scope_owner_type` | string | — | — |
| `secret_ref` | string | — | — |
| `signature_algo` | string | sí | — |
| `timeout_ms` | integer | — | — |
| `updated_at` | string | — | — |
| `webhook_url` | string | — | — |

**Respuestas de error**

| HTTP | response_code | Descripción |
|---|---|---|
| 400 | `INVALID_REQUEST` | Invalid request. Check the required fields and try again. |
| 401 | `UNAUTHORIZED` | Unauthorized. Verify your session or credentials. |
| 403 | `FORBIDDEN` | You do not have permission to perform this action. |
| 429 | `TOO_MANY_REQUESTS` | Too many requests. Please retry after a short delay. |
| 500 | `INTERNAL_ERROR` | An unexpected error occurred. Please try again later. |
| 503 | `SERVICE_UNAVAILABLE` | A required service is temporarily unavailable. Please try again later. |

### List webhook features by payment method [#get-webhook-features]

```http
GET /api/public/v1/webhook/features
```

Host: `https://api-baas-sandbox.tilopay.com` — requiere `Authorization: Bearer <access_token>`.

Returns feature IDs and the `event_types` each feature can emit.
Tenant scope is taken from the access token; clients must not send `tenant_id`.

**Naming:** `event_types` use `payment.*` for resource-level outcomes
(`payment.succeeded`, `payment.failed`, `payment.reversed`) and `payin.posted` for the inbound
direction only (funds received). There is no `payout.posted` and no
`transaction.*` events. The delivered JSON object is always `payment`.

Inspect payload shapes with `GET /api/public/v1/webhook/event-contracts/{event_type}`
(`payin.posted`, `payment.succeeded`, `payment.failed`, `payment.reversed`).

**Parámetros**

| Parámetro | En | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | — | Optional client-supplied correlation id for end-to-end tracing. Echoed back as `correlation_id` in the response envelope. If omitted, the API generates one and still returns it. |
| `country_code` | query | string | — | — |
| `payment_method_code` | query | string | — | — |
| `webhook_enabled` | query | boolean | — | — |

**200** — The request was processed successfully.

`response_code`: `OK`

Campos de `data`:

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `items` | array<object> | sí | — |
| `items[].event_types` | array<string> | sí | — |
| `items[].id` | string | sí | — |

**Respuestas de error**

| HTTP | response_code | Descripción |
|---|---|---|
| 401 | `UNAUTHORIZED` | Unauthorized. Verify your session or credentials. |
| 403 | `FORBIDDEN` | You do not have permission to perform this action. |
| 429 | `TOO_MANY_REQUESTS` | Too many requests. Please retry after a short delay. |
| 500 | `INTERNAL_ERROR` | An unexpected error occurred. Please try again later. |
| 503 | `SERVICE_UNAVAILABLE` | A required service is temporarily unavailable. Please try again later. |

### Webhook event contract [#get-webhook-event-contracts-event-type]

```http
GET /api/public/v1/webhook/event-contracts/{event_type}
```

Host: `https://api-baas-sandbox.tilopay.com` — requiere `Authorization: Bearer <access_token>`.

Returns the outbound webhook payload contract for one public event type
(`schema_version` 1.0.0). The envelope object is always `payment` (never
`payin`, `payout`, or `transaction`).

**When to use each name**
- **payment** — resource and envelope. Outcome events: `payment.succeeded`,
  `payment.failed`, `payment.reversed` (both PAYIN and PAYOUT). Correlate with REST
  `/transactions/payments`.
- **payin / payout** — direction (`PAYIN` inbound, `PAYOUT` outbound).
  `payin.posted` is inbound-only (ledger posted / funds received).
  There is no public `payout.posted`.
- **transaction** — REST path prefix only. Not used in webhook `event_type`
  or payload objects.

Supported `event_type` values:
- `payin.posted` — inbound (PAYIN) payment reached public status `posted`.
  Envelope field is `payment`. `payment.local_payment` is a JSON boolean
  (`true`/`false`), not a string.
- `payment.succeeded` — the provider confirmed success for a payment (PAYIN or
  PAYOUT; PIN or SINPE Móvil). Resource-level outcome, not direction-specific.
- `payment.failed` — processing ended in a definitive failure (public `status=failed`),
  with structured `payment.error` (`domain` / `platform`). When the provider rejected
  the payment, the envelope also includes `rejection`. Not used for later reversals.
- `payment.reversed` — subsequent reversal of a payment that already progressed
  (typically `status=posted` or `confirmed`). REST `result=reversed` and
  `has_reversal=true`. Processing `status` is unchanged. Not a `payment.failed`.

Unknown `event_type` values (including internal-only `payment.status_changed`)
return `404`.

## SINPE rejection codes (`rejection`, only on `payment.failed`)

When `event_type = payment.failed` and the provider rejected the payment, the
envelope includes `rejection` (same object and catalog as `data.rejection` on
`POST /accounts/validate`). `rejection.code` is one of a fixed set of normalized
semantics (see the `WebhookRejection.code` schema enum). **Most rows in the table
below do NOT get a distinct `code`** — only a subset of `reason_code` values has a
dedicated semantic; every other `reason_code`, even one listed below with a specific
`message`, returns `code = SINPE_REJECTED` (e.g. `reason_code = 21` "fondos
insuficientes" still returns `SINPE_REJECTED`). A `reason_code` not listed here at
all still produces a response — `message` falls back to a generic text and `code`
falls back to `SINPE_REJECTED`.

**Cuenta / perfil / límites**

| Code | Message |
|---|---|
| 21 | Cuenta Cliente con fondos insuficientes |
| 22 | Cuenta Cliente no admite créditos |
| 23 | Cuenta Cliente cerrada |
| 24 | Cuenta Cliente inactiva |
| 25 | Cuenta Cliente no admite débitos |
| 26 | Cuenta Cliente no es de fondos |
| 27 | Moneda de la Cuenta Cliente no corresponde |
| 28 | Cuenta cliente no existe |
| 29 | Cuenta Cliente no registrada en el SINPE |
| 30 | Cuenta Cliente no habilitada para el servicio |
| 31 | Cuenta Cliente bloqueada |
| 32 | Id cliente destino no coincide con registrado en la entidad |
| 33 | Nombre del cliente destino no coincide con el registrado en la entidad |
| 34 | Cuenta Cliente en proceso de cierre |
| 35 | Cuenta Cliente embargada |
| 36 | Cuenta Cliente con retención judicial |
| 37 | Cuenta de expediente simplificado no permite el monto indicado |
| 38 | Límite transaccional de la Cuenta Cliente excedido |
| 39 | Cuenta Cliente incorrecta |
| 40 | IBAN de la cuenta destino inválido |
| 41 | IBAN de la cuenta origen inválido |
| 42 | Tipo de cuenta no permite la transacción |
| 43 | Cuenta Cliente no pertenece a la entidad indicada |
| 44 | Producto de la cuenta no admite el servicio |
| 45 | Cuenta Cliente en estado de cancelación |
| 46 | Cuenta Cliente restringida por política de la entidad |
| 47 | Cuenta Cliente no permite pagos inmediatos |
| 48 | Cuenta Cliente no permite SINPE Móvil |
| 49 | Titular de la cuenta destino fallecido |
| 50 | Cuenta Cliente consolidada o migrada |
| 51 | Identificación del cliente origen no coincide |
| 52 | Identificación del cliente destino inválida |
| 53 | Cliente destino no autorizado para recibir el pago |
| 54 | Cliente origen no autorizado para enviar el pago |
| 55 | Perfil del cliente origen no permite la transacción |
| 56 | Monto inferior al mínimo permitido |
| 57 | Monto superior al máximo permitido |
| 58 | Cantidad de transacciones diarias excedida |
| 59 | Cantidad de transacciones mensuales excedida |
| 60 | Límite acumulado diario excedido |
| 61 | Límite acumulado mensual excedido |
| 62 | Comisión no pudo ser aplicada |
| 63 | Tipo de cambio no disponible |
| 64 | Transacción rechazada por control de lavado de dinero |
| 65 | Transacción rechazada por listas de control |
| 66 | Transacción en revisión de cumplimiento |
| 67 | Documento de respaldo requerido no presente |
| 68 | Firma o autenticación inválida |
| 69 | Token o segundo factor inválido |
| 70 | Sesión de usuario expirada |
| 71 | Usuario no autorizado para el canal |
| 72 | Dispositivo no registrado |
| 73 | Geolocalización no permitida |
| 74 | Operación no soportada en la moneda indicada |
| 75 | Operación no soportada para el tipo de cliente |
| 76 | Problemas de comunicación |
| 77 | Tiempo de espera agotado en la entidad origen |
| 78 | Tiempo de espera agotado en el SINPE |
| 79 | Error interno de la entidad origen |
| 80 | Error interno de la entidad destino |
| 81 | Entidad origen no disponible |
| 82 | Entidad destino no encontrada |
| 83 | Problemas en la respuesta del destino |
| 84 | Respuesta de la entidad origen incorrecta |
| 85 | Mensaje con formato electrónico inválido |
| 86 | Versión del estándar electrónico no soportada |
| 87 | Campo obligatorio no informado |
| 88 | Campo con valor fuera de catálogo |
| 89 | Checksum o integridad del mensaje inválida |
| 90 | Referencia SINPE duplicada |
| 91 | Moneda no corresponde |
| 92 | Transacción no autorizada por entidad destino |
| 93 | Transacción no autorizada por cliente destino |
| 94 | Transacción no autorizada por entidad origen |
| 95 | Transacción no autorizada por cliente origen |
| 96 | Reverso no permitido para el estado de la transacción |
| 97 | Reverso ya aplicado |
| 98 | Confirmación no permitida para el estado de la transacción |
| 99 | Liquidación no permitida para el estado de la transacción |
| 100 | Entidad Destino no disponible para procesar en tiempo real |
| 101 | Entidad origen no disponible para procesar en tiempo real |
| 102 | Servicio PIN no habilitado para la entidad destino |
| 103 | Servicio PIN no habilitado para la entidad origen |
| 104 | Código de entidad destino inválido |
| 105 | Código de entidad origen inválido |
| 106 | Código de país de la entidad destino inválido |
| 107 | Código de país de la entidad origen inválido |
| 108 | Número de referencia interna inválido |
| 109 | Número de referencia SINPE inválido |
| 110 | Transacción no se encuentra en un estado que permita la consulta |

**Compensación con entidad destino**

| Code | Message |
|---|---|
| 201 | Tiempo respuesta excedido por la entidad destino |
| 202 | Respuesta de la entidad destino incorrecta según el estándar electrónico |
| 203 | Se recibió una excepción de la entidad destino |
| 204 | Error de comunicación con la entidad destino |
| 205 | Falló procesamiento en el SINPE |
| 206 | Transacción no autorizada por cliente destino |
| 207 | Perfil transaccional del cliente destino no permite recibir el pago |
| 208 | Falló la acreditación en la cuenta destino |
| 209 | Falló el débito en la cuenta origen |
| 210 | Conciliación de la transacción no fue posible |

**Identificación**

| Code | Message |
|---|---|
| 801 | Identificación inválida |
| 802 | Identificación del cliente origen no encontrada |
| 803 | Identificación del cliente destino no encontrada |
| 804 | Identificación no vigente |
| 805 | Identificación vencida |
| 806 | Identificación no corresponde al tipo indicado |
| 807 | Tipo de identificación inválido |
| 808 | País de la identificación no soportado |
| 809 | Identificación de menor de edad no permitida |
| 810 | Identificación de persona jurídica no permitida para el servicio |

**Validación de formato / Core Bancario**

| Code | Message |
|---|---|
| 1001 | Cuenta cliente activa |
| 1002 | El Id de cliente destino no cumple con el formato esperado por el SINPE |
| 1003 | El Id de cliente origen no fue informado |
| 1004 | El Id de cliente origen no cumple con el formato esperado por el SINPE |
| 1005 | Monto con formato inválido |
| 1006 | Moneda con formato inválido |
| 1007 | Problemas de comunicación con el Core Bancario |
| 1008 | Core Bancario no disponible |
| 1009 | El valor para el campo no puede ser nulo o infringir su longitud mínima o máxima |
| 1010 | El valor para el campo no corresponde al tipo de dato esperado |
| 1011 | El valor para el campo no corresponde al catálogo permitido |
| 1012 | Fecha con formato inválido |
| 1013 | Hora con formato inválido |
| 1014 | Número de referencia con formato inválido |
| 1015 | IBAN con formato inválido |

**Canal / tipo de identificación**

| Code | Message |
|---|---|
| 1040 | Canal no informado |
| 1041 | Canal inválido |
| 1042 | Canal no corresponde |
| 1043 | Canal no habilitado para la entidad |
| 1044 | Canal no habilitado para el servicio |
| 1045 | El formato de la identificación es inválido |
| 1046 | Tipo de identificación no informado |
| 1080 | Tipo de identificación no corresponde al cliente origen |
| 1081 | Tipo de identificación no corresponde al cliente destino |
| 1082 | Tipo de identificación no vigente |
| 1083 | Tipo de identificación no soportado por el servicio |
| 1084 | Tipo de identificación inválido |
| 1085 | Tipo de identificación no soportado por la entidad |

**SINPE Móvil (monedero)**

| Code | Message |
|---|---|
| 15300 | El número de teléfono origen indicado es inválido |
| 15301 | El número de teléfono origen no tiene activo el Servicio Monedero |
| 15302 | El número de teléfono destino indicado es inválido |
| 15303 | El número de teléfono destino no está registrado en el padrón móvil del BCCR |
| 15304 | No es posible inactivar el monedero indicado pues no existe |
| 15305 | El número de teléfono indicado ya se encuentra activo como monedero en el padrón local |

⚠️ This catalog is under review — some entries (notably `208` and `209`)
are known to be pending verification against the official SINPE source and
may be corrected in a future revision without notice.

Delivery body is JSON. `EXTERNAL_WEBHOOK` HTTP body is always `{"data":"<ciphertext>"}`
(AES-256-GCM, key = SHA256(secret_ref)). Decrypt to obtain the event envelope documented
per `GET /webhook/event-contracts/{event_type}`.
and the decrypted plaintext matches this contract.

**Parámetros**

| Parámetro | En | Tipo | Obligatorio | Descripción |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | — | Optional client-supplied correlation id for end-to-end tracing. Echoed back as `correlation_id` in the response envelope. If omitted, the API generates one and still returns it. |
| `event_type` | path | string | sí | Values: payin.posted, payment.succeeded, payment.failed, payment.reversed |

**200** — The request was processed successfully.

`response_code`: `OK`

Campos de `data`:

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `description` | string | sí | — |
| `event_type` | string | sí | — |
| `example` | object | sí | — |
| `schema_version` | string | sí | — |

**Respuestas de error**

| HTTP | response_code | Descripción |
|---|---|---|
| 401 | `UNAUTHORIZED` | Unauthorized. Verify your session or credentials. |
| 403 | `FORBIDDEN` | You do not have permission to perform this action. |
| 404 | `NOT_FOUND` | The requested resource was not found. |
| 429 | `TOO_MANY_REQUESTS` | Too many requests. Please retry after a short delay. |
| 500 | `INTERNAL_ERROR` | An unexpected error occurred. Please try again later. |
| 503 | `SERVICE_UNAVAILABLE` | A required service is temporarily unavailable. Please try again later. |

## Contratos de payload [#contratos]

Cada evento declara su propio contrato de campos. Estos son los que el spec publica:

### payin.posted [#contrato-payin-posted]

Contrato del payload que el spec declara para `payin.posted`.

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `schema_version` | string | sí | — |
| `event` | object | sí | — |
| `event.event_id` | string (uuid) | sí | — |
| `event.event_type` | string | sí | Values: payin.posted |
| `event.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `event.correlation_id` | string | sí | — |
| `context` | object | sí | — |
| `context.country_code` | string | sí | — |
| `context.payment_method_code` | string | sí | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes such as `PM_PIN` are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | sí | — |
| `payment.payment_id` | string (uuid) | sí | — |
| `payment.public_id` | integer (int64) | sí | — |
| `payment.status` | string | sí | Public payment status catalog. Always lowercase. Same values on REST (create, list, get) and webhooks (`payment.status`). Internal lifecycle states are collapsed: - `pending`: initiated, validated, accepted - `processing`: processing, pending_processing, posting - `confirmed`: confirmed by the rail, not yet ledger-posted - `posted`: ledger posted (stays `posted` after a later reversal) - `failed`: failed, rejected, posting_failed, cancelled, expired A reversal is **not** a status. Use REST `result=reversed` / `has_reversal=true` and webhook `payment.reversed`. Operational detail remains in `status_detail` (uppercase internal name) on REST create/list/get. Webhook `payment.failed` uses `error.platform.code` to distinguish posting failures from provider rejections. Values: pending, processing, confirmed, posted, failed |
| `payment.amount` | object | sí | — |
| `payment.amount.amount` | string | — | — |
| `payment.amount.currency` | string | — | — |
| `payment.posted_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `payment.account` | object | sí | — |
| `payment.account.type` | string | sí | — |
| `payment.account.value` | string | sí | — |
| `payment.reference` | object | sí | — |
| `payment.reference.client_reference` | string | sí | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.reference.external_reference` | string | sí | — |
| `payment.destination_phone_number` | string | — | — |
| `payment.local_payment` | boolean | — | JSON boolean (`true`/`false`), not the strings `"true"`/`"false"`. |
| `payment.origin_client_name` | string | — | — |
| `provider` | object | sí | Shared provider snapshot on REST payment reads and webhook envelopes. Rail reference is only on `payment.external_reference`, not duplicated here. `provider_status_*` match webhook `rejection.reason_code` / `message` / `code` when the provider rejected the operation. `null` when there is no rejection. |
| `provider.correlation_id` | string | sí | Transaction correlation UUID (`execution.correlation_id` / `metadata.tx.correlationId`). Sent to GX/SINPE as `correlationId`. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. |
| `provider.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `provider.provider_status_code` | string | sí | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. |
| `provider.provider_status_desc` | string | sí | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. |
| `provider.provider_status_semantic` | string | sí | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. |

### payment.succeeded [#contrato-payment-succeeded]

Contrato del payload que el spec declara para `payment.succeeded`.

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `schema_version` | string | sí | — |
| `event` | object | sí | — |
| `event.event_id` | string (uuid) | sí | — |
| `event.event_type` | string | sí | Values: payment.succeeded |
| `event.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `event.correlation_id` | string | sí | — |
| `context` | object | sí | — |
| `context.country_code` | string | sí | — |
| `context.payment_method_code` | string | sí | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes such as `PM_PIN` are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | sí | — |
| `payment.payment_id` | string (uuid) | sí | — |
| `payment.public_id` | integer (int64) | sí | — |
| `payment.status` | string | sí | Public payment status catalog. Always lowercase. Same values on REST (create, list, get) and webhooks (`payment.status`). Internal lifecycle states are collapsed: - `pending`: initiated, validated, accepted - `processing`: processing, pending_processing, posting - `confirmed`: confirmed by the rail, not yet ledger-posted - `posted`: ledger posted (stays `posted` after a later reversal) - `failed`: failed, rejected, posting_failed, cancelled, expired A reversal is **not** a status. Use REST `result=reversed` / `has_reversal=true` and webhook `payment.reversed`. Operational detail remains in `status_detail` (uppercase internal name) on REST create/list/get. Webhook `payment.failed` uses `error.platform.code` to distinguish posting failures from provider rejections. Values: pending, processing, confirmed, posted, failed |
| `payment.amount` | object | sí | — |
| `payment.amount.amount` | string | — | — |
| `payment.amount.currency` | string | — | — |
| `payment.client_reference` | string | sí | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.external_reference` | string | sí | — |
| `payment.fee_total` | string | sí | Fee total as decimal string (same currency as amount.currency) |
| `payment.tax_total` | string | sí | Tax total as decimal string (same currency as amount.currency) |
| `payment.net_amount` | string | sí | Net cash impact on the merchant account. PAYIN: amount - fee_total - tax_total (what the merchant receives). PAYOUT: amount + fee_total + tax_total (total debit; fee is charged separately and is not deducted from the transferred amount). |
| `payment.succeeded_at` | string | sí | Timestamp when the provider confirmed the payment succeeded. |
| `provider` | object | sí | Shared provider snapshot on REST payment reads and webhook envelopes. Rail reference is only on `payment.external_reference`, not duplicated here. `provider_status_*` match webhook `rejection.reason_code` / `message` / `code` when the provider rejected the operation. `null` when there is no rejection. |
| `provider.correlation_id` | string | sí | Transaction correlation UUID (`execution.correlation_id` / `metadata.tx.correlationId`). Sent to GX/SINPE as `correlationId`. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. |
| `provider.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `provider.provider_status_code` | string | sí | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. |
| `provider.provider_status_desc` | string | sí | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. |
| `provider.provider_status_semantic` | string | sí | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. |

### payment.failed [#contrato-payment-failed]

Contrato del payload que el spec declara para `payment.failed`.

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `schema_version` | string | sí | — |
| `event` | object | sí | — |
| `event.event_id` | string (uuid) | sí | — |
| `event.event_type` | string | sí | Values: payment.failed |
| `event.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `event.correlation_id` | string | sí | — |
| `context` | object | sí | — |
| `context.country_code` | string | sí | — |
| `context.payment_method_code` | string | sí | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes such as `PM_PIN` are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | sí | — |
| `payment.payment_id` | string (uuid) | sí | — |
| `payment.public_id` | integer (int64) | sí | — |
| `payment.status` | string | sí | Public payment status catalog. Always lowercase. Same values on REST (create, list, get) and webhooks (`payment.status`). Internal lifecycle states are collapsed: - `pending`: initiated, validated, accepted - `processing`: processing, pending_processing, posting - `confirmed`: confirmed by the rail, not yet ledger-posted - `posted`: ledger posted (stays `posted` after a later reversal) - `failed`: failed, rejected, posting_failed, cancelled, expired A reversal is **not** a status. Use REST `result=reversed` / `has_reversal=true` and webhook `payment.reversed`. Operational detail remains in `status_detail` (uppercase internal name) on REST create/list/get. Webhook `payment.failed` uses `error.platform.code` to distinguish posting failures from provider rejections. Values: pending, processing, confirmed, posted, failed |
| `payment.amount` | object | sí | — |
| `payment.amount.amount` | string | — | — |
| `payment.amount.currency` | string | — | — |
| `payment.client_reference` | string | — | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.external_reference` | string | — | — |
| `payment.error` | object | sí | — |
| `payment.error.domain` | string | sí | Values: provider_rejection, platform_validation, platform_posting, platform_infra, platform_reversal, sinpe_rejection |
| `payment.error.platform` | object | sí | — |
| `provider` | object | sí | Shared provider snapshot on REST payment reads and webhook envelopes. Rail reference is only on `payment.external_reference`, not duplicated here. `provider_status_*` match webhook `rejection.reason_code` / `message` / `code` when the provider rejected the operation. `null` when there is no rejection. |
| `provider.correlation_id` | string | sí | Transaction correlation UUID (`execution.correlation_id` / `metadata.tx.correlationId`). Sent to GX/SINPE as `correlationId`. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. |
| `provider.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `provider.provider_status_code` | string | sí | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. |
| `provider.provider_status_desc` | string | sí | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. |
| `provider.provider_status_semantic` | string | sí | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. |
| `rejection` | object | — | Rail-agnostic provider rejection. Shared by `POST /accounts/validate` (`data.rejection`) and the `payment.failed` webhook (`rejection`). Object only when the provider rejected the operation; omitted/`null` otherwise. |
| `rejection.code` | string | sí | Normalized platform semantic derived from `reason_code`. Only a subset of `reason_code` values (see the catalog in the description of `POST /accounts/validate` and of the `payment.failed` webhook contract endpoint) has a distinct entry here — for any other numeric `reason_code`, `code` is `SINPE_REJECTED` even when `message` is specific (e.g. `reason_code = 21` "fondos insuficientes" still returns `code = SINPE_REJECTED`, it has no dedicated semantic). `PHONE_NOT_REGISTERED` is the fallback when `reason_code` is non-numeric (SINPE_MOVIL wallet flow). Values: ACCOUNT_CLOSED, ACCOUNT_NOT_FOUND, ACCOUNT_BLOCKED, INVALID_ACCOUNT, IDENT_MISMATCH, IDENT_TYPE_INVALID, INVALID_ID_FORMAT, VALIDATION_ERROR, CURRENCY_MISMATCH, NOT_AUTHORIZED_BY_RECIPIENT, PROFILE_NOT_ALLOWED, CHANNEL_NOT_RECOGNIZED, COMMUNICATION_ERROR, RETRY_LATER, WALLET_PHONE_ORIGIN_INVALID, WALLET_PHONE_ORIGIN_NOT_ENABLED, WALLET_PHONE_DEST_INVALID, WALLET_PHONE_NOT_REGISTERED_BCCR, WALLET_NOT_FOUND, WALLET_PHONE_ALREADY_ACTIVE, PHONE_NOT_REGISTERED, SINPE_REJECTED |
| `rejection.reason_code` | string | sí | Raw Motivo/code returned by SINPE. See the endpoint description of `POST /accounts/validate` for the full code catalog (shared by this object wherever it appears, including the `payment.failed` webhook). A code not listed there still produces a response — `message` falls back to a generic text and `code` falls back to `SINPE_REJECTED`. |
| `rejection.message` | string | sí | Descriptive Motivo text; prefers provider `Detalle` when present. |

### payment.reversed [#contrato-payment-reversed]

Contrato del payload que el spec declara para `payment.reversed`.

| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| `schema_version` | string | sí | — |
| `event` | object | sí | — |
| `event.event_id` | string (uuid) | sí | — |
| `event.event_type` | string | sí | Values: payment.reversed |
| `event.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `event.correlation_id` | string | sí | — |
| `context` | object | sí | — |
| `context.country_code` | string | sí | — |
| `context.payment_method_code` | string | sí | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes such as `PM_PIN` are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | sí | — |
| `payment.payment_id` | string (uuid) | sí | — |
| `payment.public_id` | integer (int64) | sí | — |
| `payment.status` | string | sí | Public payment status catalog. Always lowercase. Same values on REST (create, list, get) and webhooks (`payment.status`). Internal lifecycle states are collapsed: - `pending`: initiated, validated, accepted - `processing`: processing, pending_processing, posting - `confirmed`: confirmed by the rail, not yet ledger-posted - `posted`: ledger posted (stays `posted` after a later reversal) - `failed`: failed, rejected, posting_failed, cancelled, expired A reversal is **not** a status. Use REST `result=reversed` / `has_reversal=true` and webhook `payment.reversed`. Operational detail remains in `status_detail` (uppercase internal name) on REST create/list/get. Webhook `payment.failed` uses `error.platform.code` to distinguish posting failures from provider rejections. Values: pending, processing, confirmed, posted, failed |
| `payment.result` | string | sí | Values: reversed |
| `payment.has_reversal` | boolean | sí | Values: true |
| `payment.amount` | object | sí | — |
| `payment.amount.amount` | string | — | — |
| `payment.amount.currency` | string | — | — |
| `payment.client_reference` | string | — | — |
| `payment.external_reference` | string | — | — |
| `payment.reversed_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `payment.reason` | object | — | — |
| `payment.reason.code` | string | — | — |
| `payment.reason.message` | string | — | — |
| `provider` | object | sí | Shared provider snapshot on REST payment reads and webhook envelopes. Rail reference is only on `payment.external_reference`, not duplicated here. `provider_status_*` match webhook `rejection.reason_code` / `message` / `code` when the provider rejected the operation. `null` when there is no rejection. |
| `provider.correlation_id` | string | sí | Transaction correlation UUID (`execution.correlation_id` / `metadata.tx.correlationId`). Sent to GX/SINPE as `correlationId`. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. |
| `provider.occurred_at` | string (date-time) | sí | RFC 3339 timestamp in UTC with second precision and a `Z` suffix. Fractional seconds are never emitted. Example: `2026-01-15T12:00:00Z`. |
| `provider.provider_status_code` | string | sí | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. |
| `provider.provider_status_desc` | string | sí | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. |
| `provider.provider_status_semantic` | string | sí | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. |
