# Banking API webhooks

> Destination registration, subscribable features and payload contracts for Banking API webhooks.

- kind: api-operation
- status: stable
- api_version: 1.0.0
- last_verified: 2026-10-22
- url: https://tilopay.com/en/developers/api-bancario/webhooks

The `Webhooks` group lets you register the HTTPS destination where you receive events, see
which features you can subscribe to per payment method and fetch each event's payload
contract before writing the receiver.

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

## Events [#eventos]

Our API notifies you of four event types:

| Event | When it arrives |
|---|---|
| `payin.posted` | An inbound movement was credited. |
| `payment.succeeded` | The payment finished successfully. |
| `payment.failed` | The payment finished with a failure. |
| `payment.reversed` | The payment was reversed. |

A reversed payment keeps the `posted` state and is told apart by `result: reversed` and
`has_reversal: true`; see [payment states](/en/developers/api-bancario/pagos#estados).

## Receiver best practices [#receptor]

Delivery is attempted up to **3 times per event** and **any HTTP `2xx`** counts as success. Respond `2xx` as soon as you receive the event and process it separately: a slow receiver
causes redeliveries. Treat events as potentially repeated and deduplicate by the envelope's
`event.event_id` (retries redeliver the same event with the same `event_id`). And do not trust the final state to the webhook alone: confirm it with
[the payment lookup](/en/developers/api-bancario/pagos#operaciones).

## Operations [#operaciones]

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

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

Host: `https://api-baas-sandbox.tilopay.com` — requires `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`.

Delivery is attempted up to 3 times per event. Any HTTP 2xx response counts as successful delivery.

**Parameters**

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | no | 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. |

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `feature_id` | string (uuid) | yes | Feature to subscribe (from `GET /api/public/v1/webhook/features`). Must exist or create fails. |
| `is_enabled` | boolean | no | Defaults to true if omitted. |
| `destination_kind` | string | yes | Public API accepts only `EXTERNAL_WEBHOOK` (HTTPS callback with shared secret). `INTERNAL` destinations are managed administratively. Values: EXTERNAL_WEBHOOK |
| `destination_ref` | string | no | Not used on the public API. Reserved for internal destinations. Can be null. |
| `webhook_url` | string (uri) | no | Required when `destination_kind = EXTERNAL_WEBHOOK` (must use `https://`). Can be null. |
| `secret_ref` | string | no | Required when `destination_kind = EXTERNAL_WEBHOOK`: shared secret used to decrypt AES_GCM payloads (`key = SHA256(secret_ref)`). See "Webhook encryption" in this operation's description. Can be null. |
| `signature_algo` | string | no | Fixed to `AES_GCM` for `EXTERNAL_WEBHOOK`. Omitted on create; stored as `AES_GCM`. `HMAC_SHA256` is no longer supported. Can be null. Values: AES_GCM |
| `replay_window_sec` | integer | no | Reserved for legacy destinations. Not used for `EXTERNAL_WEBHOOK` AES_GCM deliveries. Can be null. |
| `timeout_ms` | integer | no | HTTP timeout for delivery attempts (`EXTERNAL_WEBHOOK`). Can be null. |
| `max_attempts` | integer | no | Delivery attempts per event, up to 3. Can be null. |
| `scope_kind` | string | yes | - `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) | no | Required when `scope_kind` is `ACCOUNT`. Must be omitted for `TENANT` and `OWNER`. Can be null. |
| `scope_owner_type` | string | no | Required when `scope_kind` is `OWNER`. Allowed values (lowercase) `tenant`, `partner`, `user`, `platform`, `customer`. Omit for `TENANT` and `ACCOUNT`. Can be null. |
| `scope_owner_id` | string (uuid) | no | Required when `scope_kind` is `OWNER`. Omit for `TENANT` and `ACCOUNT`. Can be null. |

**Request example** — external_webhook_tenant

```json
{
  "feature_id": "11111111-1111-1111-1111-111111111111",
  "destination_kind": "EXTERNAL_WEBHOOK",
  "scope_kind": "TENANT",
  "webhook_url": "https://example.com/hooks/tilopay",
  "secret_ref": "whsec_example_shared_secret"
}
```

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

Fields in `data`:

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | string (uuid) | no | — |
| `feature_id` | string (uuid) | no | — |
| `is_enabled` | boolean | no | — |
| `destination_kind` | string | no | — |
| `destination_ref` | string | no | — |
| `webhook_url` | string | no | — |
| `secret_ref` | string | no | — |
| `signature_algo` | string | no | — |
| `replay_window_sec` | integer | no | — |
| `timeout_ms` | integer | no | — |
| `max_attempts` | integer | no | Delivery attempts per event, up to 3. |
| `scope_kind` | string | no | — |
| `scope_account_id` | string (uuid) | no | — |
| `scope_owner_type` | string | no | — |
| `scope_owner_id` | string (uuid) | no | — |
| `created_at` | string | no | — |
| `updated_at` | string | no | — |

**Response example**

```json
{
  "success": true,
  "http_status_code": 201,
  "response_code": "CREATED",
  "message": "The resource was created successfully.",
  "correlation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "data": {
    "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "feature_id": "11111111-1111-1111-1111-111111111111",
    "is_enabled": true,
    "destination_kind": "EXTERNAL_WEBHOOK",
    "destination_ref": null,
    "webhook_url": "https://example.com/hooks/tilopay",
    "secret_ref": "whsec_example_shared_secret",
    "signature_algo": "AES_GCM",
    "replay_window_sec": null,
    "timeout_ms": 10000,
    "max_attempts": 8,
    "scope_kind": "TENANT",
    "scope_account_id": null,
    "scope_owner_type": null,
    "scope_owner_id": null,
    "created_at": "2026-01-15T12:00:00Z",
    "updated_at": "2026-01-15T12:00:00Z"
  }
}
```

**Error responses**

| HTTP | response_code | Description |
|---|---|---|
| 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. |
| 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` — requires `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`).

**Parameters**

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | no | 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 | no | — |
| `payment_method_code` | query | string | no | — |
| `webhook_enabled` | query | boolean | no | — |

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

Fields in `data`:

| Field | Type | Required | Description |
|---|---|---|---|
| `items` | array<object> | no | — |
| `items[].id` | string (uuid) | yes | — |
| `items[].event_types` | array<string> | yes | Public event types this feature can emit. `payment.*` = resource-level outcomes (any direction). `payin.posted` = inbound direction only (funds received). There is no `payout.posted` and no `transaction.*`. Inspect payload shapes with `GET /api/public/v1/webhook/event-contracts/{event_type}`. |

**Response example**

```json
{
  "success": true,
  "http_status_code": 200,
  "response_code": "OK",
  "message": "The request was processed successfully.",
  "correlation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "data": {
    "items": [
      {
        "id": "11111111-1111-1111-1111-111111111111",
        "event_types": [
          "payin.posted",
          "payment.succeeded",
          "payment.failed",
          "payment.reversed"
        ]
      }
    ]
  }
}
```

**Error responses**

| HTTP | response_code | Description |
|---|---|---|
| 401 | `UNAUTHORIZED` | Unauthorized. Verify your session or credentials. |
| 403 | `FORBIDDEN` | You do not have permission to perform this action. |
| 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` — requires `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 value table below). **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`.

**Account / profile / limits**

| 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 |

**Settlement with destination institution**

| 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 | Operación no autorizada entre las entidades participantes |
| 209 | Pendientes de Cumplimiento |
| 210 | Conciliación de la transacción no fue posible |

**Identification**

| 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 |

**Format validation / Core Banking**

| Code | Message |
|---|---|
| 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 |

**Channel / identification type**

| 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 (wallet)**

| 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 |

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}`; the decrypted plaintext matches that contract.


**`rejection.code` values**

| Value | Meaning |
|---|---|
| `ACCOUNT_CLOSED` | Account closed |
| `ACCOUNT_NOT_FOUND` | Account not found |
| `ACCOUNT_BLOCKED` | Account blocked |
| `INVALID_ACCOUNT` | Invalid account |
| `IDENT_MISMATCH` | Identification does not match |
| `IDENT_TYPE_INVALID` | Invalid identification type |
| `INVALID_ID_FORMAT` | Invalid identification format |
| `VALIDATION_ERROR` | Validation error |
| `CURRENCY_MISMATCH` | Currency mismatch |
| `NOT_AUTHORIZED_BY_RECIPIENT` | Not authorized by the recipient |
| `PROFILE_NOT_ALLOWED` | Profile not allowed |
| `CHANNEL_NOT_RECOGNIZED` | Channel not recognized |
| `COMMUNICATION_ERROR` | Communication error |
| `RETRY_LATER` | Retry later |
| `WALLET_PHONE_ORIGIN_INVALID` | Origin phone invalid (SINPE Móvil) |
| `WALLET_PHONE_ORIGIN_NOT_ENABLED` | Origin phone without wallet service |
| `WALLET_PHONE_DEST_INVALID` | Destination phone invalid (SINPE Móvil) |
| `WALLET_PHONE_NOT_REGISTERED_BCCR` | Phone not registered in the BCCR mobile registry |
| `WALLET_NOT_FOUND` | Wallet not found |
| `WALLET_PHONE_ALREADY_ACTIVE` | Phone already has an active wallet |
| `PHONE_NOT_REGISTERED` | Phone not registered |
| `SINPE_REJECTED` | Rejected by SINPE (generic fallback) |

Delivery is attempted up to 3 times per event. Any HTTP 2xx response counts as successful delivery.

**Parameters**

| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
| `X-Correlation-Id` | header | string | no | 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 | yes | Values: payin.posted, payment.succeeded, payment.failed, payment.reversed |

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

Fields in `data`:

| Field | Type | Required | Description |
|---|---|---|---|
| `event_type` | string | yes | Values: payin.posted, payment.succeeded, payment.failed, payment.reversed |
| `schema_version` | string | yes | — |
| `description` | string | yes | — |
| `example` | object | yes | — |

**Response example**

```json
{
  "success": true,
  "http_status_code": 200,
  "response_code": "OK",
  "message": "The request was processed successfully.",
  "correlation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "data": {
    "event_type": "payment.succeeded",
    "schema_version": "1.0.0",
    "description": "Envelope delivered for `payment.succeeded` (decrypt AES_GCM `data` field first).",
    "example": {
      "event_id": "evt_01JEXAMPLE",
      "event_type": "payment.succeeded",
      "schema_version": "1.0.0"
    },
    "x-description-es": "Sobre entregado para `payment.succeeded` (descifrá primero el campo `data` AES_GCM)."
  }
}
```

**Error responses**

| HTTP | response_code | Description |
|---|---|---|
| 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. |
| 500 | `INTERNAL_ERROR` | An unexpected error occurred. Please try again later. |
| 503 | `SERVICE_UNAVAILABLE` | A required service is temporarily unavailable. Please try again later. |

## Payload contracts [#contratos]

Each event has its own data structure. This is what we send you in each one:

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

Data we send for `payin.posted`.

| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | string | yes | — |
| `event` | object | yes | — |
| `event.event_id` | string (uuid) | yes | — |
| `event.event_type` | string | yes | Values: payin.posted |
| `event.occurred_at` | string (date-time) | yes | 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 | yes | — |
| `context` | object | yes | — |
| `context.country_code` | string | yes | — |
| `context.payment_method_code` | string | yes | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | yes | — |
| `payment.payment_id` | string (uuid) | yes | — |
| `payment.public_id` | integer (int64) | yes | The same identifier is a number in webhooks and text in REST. Always treat it as text. |
| `payment.status` | string | yes | 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 | yes | — |
| `payment.amount.amount` | string | yes | Amount as a decimal string with exactly 2 fraction digits (e.g. "1000.00"). |
| `payment.amount.currency` | string | yes | ISO 4217 currency of the payment. Values: CRC, USD |
| `payment.posted_at` | string (date-time) | yes | 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 | yes | — |
| `payment.account.type` | string | yes | — |
| `payment.account.value` | string | yes | — |
| `payment.reference` | object | yes | — |
| `payment.reference.client_reference` | string | yes | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.reference.external_reference` | string | yes | — |
| `payment.destination_phone_number` | string | no | — |
| `payment.local_payment` | boolean | no | JSON boolean (`true`/`false`), not the strings `"true"`/`"false"`. |
| `payment.origin_client_name` | string | no | — |
| `provider` | object | yes | 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 | yes | Provider transaction correlation identifier. Sent to SINPE as the correlation id. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. Can be null. |
| `provider.occurred_at` | string (date-time) | yes | 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 | yes | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_desc` | string | yes | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_semantic` | string | yes | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. Can be null. |

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

Data we send for `payment.succeeded`.

| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | string | yes | — |
| `event` | object | yes | — |
| `event.event_id` | string (uuid) | yes | — |
| `event.event_type` | string | yes | Values: payment.succeeded |
| `event.occurred_at` | string (date-time) | yes | 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 | yes | — |
| `context` | object | yes | — |
| `context.country_code` | string | yes | — |
| `context.payment_method_code` | string | yes | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | yes | — |
| `payment.payment_id` | string (uuid) | yes | — |
| `payment.public_id` | integer (int64) | yes | The same identifier is a number in webhooks and text in REST. Always treat it as text. |
| `payment.status` | string | yes | 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 | yes | — |
| `payment.amount.amount` | string | yes | Amount as a decimal string with exactly 2 fraction digits (e.g. "1000.00"). |
| `payment.amount.currency` | string | yes | ISO 4217 currency of the payment. Values: CRC, USD |
| `payment.client_reference` | string | yes | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.external_reference` | string | yes | — |
| `payment.fee_total` | string | yes | Fee total as decimal string (same currency as amount.currency) |
| `payment.tax_total` | string | yes | Tax total as decimal string (same currency as amount.currency) |
| `payment.net_amount` | string | yes | 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 (date-time) | yes | Timestamp when the provider confirmed the payment succeeded. |
| `provider` | object | yes | 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 | yes | Provider transaction correlation identifier. Sent to SINPE as the correlation id. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. Can be null. |
| `provider.occurred_at` | string (date-time) | yes | 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 | yes | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_desc` | string | yes | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_semantic` | string | yes | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. Can be null. |

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

Data we send for `payment.failed`.

| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | string | yes | — |
| `event` | object | yes | — |
| `event.event_id` | string (uuid) | yes | — |
| `event.event_type` | string | yes | Values: payment.failed |
| `event.occurred_at` | string (date-time) | yes | 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 | yes | — |
| `context` | object | yes | — |
| `context.country_code` | string | yes | — |
| `context.payment_method_code` | string | yes | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | yes | — |
| `payment.payment_id` | string (uuid) | yes | — |
| `payment.public_id` | integer (int64) | yes | The same identifier is a number in webhooks and text in REST. Always treat it as text. |
| `payment.status` | string | yes | 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 | yes | — |
| `payment.amount.amount` | string | yes | Amount as a decimal string with exactly 2 fraction digits (e.g. "1000.00"). |
| `payment.amount.currency` | string | yes | ISO 4217 currency of the payment. Values: CRC, USD |
| `payment.client_reference` | string | no | Partner reference sent at payment creation (`client_reference`), not `detail_reference`. |
| `payment.external_reference` | string | no | — |
| `payment.error` | object | yes | — |
| `payment.error.domain` | string | yes | Values: provider_rejection, platform_validation, platform_posting, platform_infra, platform_reversal, sinpe_rejection |
| `payment.error.platform` | object | yes | — |
| `provider` | object | yes | 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 | yes | Provider transaction correlation identifier. Sent to SINPE as the correlation id. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. Can be null. |
| `provider.occurred_at` | string (date-time) | yes | 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 | yes | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_desc` | string | yes | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_semantic` | string | yes | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. Can be null. |
| `rejection` | object | no | 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 | yes | 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 | yes | 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 | yes | Descriptive Motivo text; prefers provider `Detalle` when present. |

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

Data we send for `payment.reversed`.

| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | string | yes | — |
| `event` | object | yes | — |
| `event.event_id` | string (uuid) | yes | — |
| `event.event_type` | string | yes | Values: payment.reversed |
| `event.occurred_at` | string (date-time) | yes | 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 | yes | — |
| `context` | object | yes | — |
| `context.country_code` | string | yes | — |
| `context.payment_method_code` | string | yes | Public payment method catalog. Same values as REST (`PIN`, `SINPE_MOVIL`). Internal codes are never exposed. Values: PIN, SINPE_MOVIL |
| `payment` | object | yes | — |
| `payment.payment_id` | string (uuid) | yes | — |
| `payment.public_id` | integer (int64) | yes | The same identifier is a number in webhooks and text in REST. Always treat it as text. |
| `payment.status` | string | yes | 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 | yes | Values: reversed |
| `payment.has_reversal` | boolean | yes | Values: true |
| `payment.amount` | object | yes | — |
| `payment.amount.amount` | string | yes | Amount as a decimal string with exactly 2 fraction digits (e.g. "1000.00"). |
| `payment.amount.currency` | string | yes | ISO 4217 currency of the payment. Values: CRC, USD |
| `payment.client_reference` | string | no | — |
| `payment.external_reference` | string | no | — |
| `payment.reversed_at` | string (date-time) | yes | 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 | no | — |
| `payment.reason.code` | string | no | — |
| `payment.reason.message` | string | no | — |
| `provider` | object | yes | 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 | yes | Provider transaction correlation identifier. Sent to SINPE as the correlation id. Not `channel_reference` (e.g. `MOBILE_APP`). Distinct from webhook `event.correlation_id` (HTTP request id for this delivery). `null` when unknown. Can be null. |
| `provider.occurred_at` | string (date-time) | yes | 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 | yes | Raw provider Motivo/code (e.g. `31`). Same meaning as webhook `rejection.reason_code`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_desc` | string | yes | Provider Motivo text (prefers Detalle). Same meaning as webhook `rejection.message`. `null` when there is no provider rejection. Can be null. |
| `provider.provider_status_semantic` | string | yes | Normalized platform semantic (e.g. `ACCOUNT_BLOCKED`). Same meaning as webhook `rejection.code`. `null` when there is no provider rejection. Can be null. |

## Public identifier [#public-id]

`payment.public_id` is a number in the webhook and text in REST; it is the same identifier. **Always treat it as text.**
