# Generar un pago --- - [Generar un pago](#generar-un-pago) - [Overview](#overview) - [Un intento por cobro](#un-intento-por-cobro) - [Elige tu modalidad](#modalidad) - [Parámetros del pago](#parametros) - [Opciones avanzadas](#advanced-options) - [Datos del pagador](#datos-del-pagador) - [Metadata](#metadata) - [Idempotencia](#idempotencia) - [Ejemplos](#ejemplos) - [Respuestas](#respuestas) ## Overview [#overview] Este es el **primer paso** de cualquier cobro por API: le dices a Efipay qué vas a cobrar y cuánto, y te devolvemos con qué seguir. Lo que recibes depende de `payment.checkout_type`: - **`redirect`** → un `url` al que rediriges a tu cliente. Nosotros capturamos el pago. - **`api`** → un `token` con el que [procesas el pago desde tu propio checkout](/checkout-transaction). Opcionalmente puedes activar **idempotencia** con el header `X-Idempotency-Enabled` y `advanced_options.references` para evitar pagos duplicados ante reintentos. Ver [Idempotencia](#idempotencia). `POST /api/v1/payment/generate-payment` ## Un intento por cobro [#un-intento-por-cobro] Cada `payment_id` que devuelve este endpoint admite **una sola transacción**, tanto si el cobro es `redirect` como si es `api`. El estado de esa transacción no importa: aprobada, rechazada, pendiente o expirada, el cobro ya no acepta otro intento. Un **cobro** (`payment_id`) y una **transacción** no son lo mismo. Este endpoint crea el cobro. La transacción nace después, cuando el pagador usa la URL de checkout o cuando llamas a [Checkout](/checkout-transaction). A partir de ese primer intento el `payment_id` queda consumido. Si el pago falla y quieres volver a intentar (otra tarjeta, otro medio, el mismo pagador), **genera un cobro nuevo**: vuelve a llamar a `/api/v1/payment/generate-payment` y usa el nuevo `payment_id` (y el nuevo `token` o `url`). :::caution Reusar el mismo `payment_id`, `token` o URL de checkout después del primer intento responde **403** con `error.code: "payment_already_used"` y el mensaje `Este cobro ya tiene una transacción y no permite reintentos`. Es el mismo código tanto si el cobro ya se pagó como si solo tuvo un intento. Ver [Un intento por cobro](/checkout-transaction#un-intento-por-cobro). ::: **Sí consume el intento** - Cualquier transacción creada: tarjeta, PSE, efectivo, Bre-B, 3DS iniciado o abandono del checkout. - Da igual el estado final: Aprobada, Rechazada, Pendiente o expirada. **No consume el intento** - Un `422` de validación (el body está mal y aún no se creó transacción). El cuerpo de `generate-payment` y de `transaction-checkout` no cambia. El flujo 3DS (`/api/v1/payment/3ds/...`) sigue sobre la transacción ya creada: eso no es un segundo cobro. ## Elige tu modalidad [#modalidad] | | `redirect` | `api` | | - | - | - | | Quién captura el medio de pago | Nosotros | Tú | | Qué recibes | `url` | `token` | | Peticiones para cobrar | 1 | 2 | | Requiere certificación PCI DSS | **No** | **Sí** | | Diseño del checkout | El nuestro, con tu logo | El tuyo | | Medios de pago | Todos los que tengas habilitados | Los que implementes | :::tip Si estás empezando, usa `redirect`. Cambiar a `api` después no obliga a rehacer nada de este paso: solo cambia el valor de `checkout_type`. ::: ## Parámetros del pago [#parametros] | Nombre del campo | Descripción | Reglas | | - | - | - | | payment | Objeto con los datos del cobro | `['required']` | | payment.description | Qué estás cobrando. Tu cliente lo ve en el checkout y en el comprobante | `['required', 'string', 'min:4', 'max:191']` | | payment.amount | Valor a cobrar, en la unidad de la moneda (no en centavos), con hasta 2 decimales. El máximo depende de la moneda: `999999999999.99` en COP y `200000000.99` en USD/EUR | `['required', 'numeric', 'min:1', 'max:999999999999.99', 'decimal:0,2']` | | payment.currency_type | Moneda del cobro. Ver [monedas](/resources#moneda) | `['required', 'string', 'in:COP,USD,EUR']` | | payment.checkout_type | `redirect` (te damos un link) o `api` (cobras tú). Ver [tipos de checkout](/resources#tipo-de-checkout) | `['required', 'string', 'in:redirect,api']` | | payment.selected_taxes | Ids de [tus impuestos](/resources#impuestos) a aplicar. **No se puede usar junto con `tax_amount`** | `['nullable', 'array', 'missing_with:payment.tax_amount']` | | payment.selected_taxes.* | Cada id debe corresponder a un impuesto **activo** | `['required', 'exists:taxes,id']` | | payment.tax_amount | Valor del impuesto ya calculado por ti. **No se puede usar junto con `selected_taxes`** | `['nullable', 'numeric', 'decimal:0,2', 'min:0', 'max:', 'missing_with:payment.selected_taxes']` | | payment.metadata | Tus propios pares clave-valor. Ver [Metadata](#metadata) | `['sometimes', 'nullable', 'metadata']` | | payment.checkout_template_id | [Plantilla de checkout](/resources#checkout-templates) que decide qué campos se le piden al cliente | `['nullable', 'exists:checkout_templates,id']` | | office | Sucursal a la que pertenece el cobro. Debe ser una de [tus sucursales](/commercio) | `['required', 'exists:offices,id']` | :::caution `selected_taxes` y `tax_amount` son excluyentes: o nos dices qué impuestos aplicar, o nos das el valor ya calculado. Enviar los dos da error de validación. ::: :::danger Si omites `payment.currency_type`, la validación **se detiene ahí** y no verás los demás errores: los límites de monto dependen de la moneda. Corrígelo y vuelve a enviar. ::: ## Opciones avanzadas [#advanced-options] Todo lo de `advanced_options` es opcional y sirve para personalizar el cobro: hasta cuándo se puede pagar, a dónde vuelve el cliente, qué medios de pago ofreces, si hay descuento o envío. | Nombre del campo | Descripción | Reglas | | - | - | - | | advanced_options | Objeto con todo lo que sigue | `['nullable']` | | advanced_options.picture | **URL** de una imagen a mostrar en el checkout. En este endpoint es una URL, no un archivo | `['nullable', 'string', 'url', 'ends_with:.jpg,.png,.jpeg']` | | advanced_options.limit_date | Hasta cuándo se puede pagar. Acepta `Y-m-d` o `Y-m-d H:i:s` | `['nullable', 'date', 'after_or_equal:today']` | | advanced_options.limit_payments | Cuántas transacciones aprobadas admite el cobro | `['nullable', 'integer']` | | advanced_options.references | Tus referencias para identificar el cobro. Hasta 3. Si activas idempotencia, son la llave para no crear un pago duplicado | `['nullable', 'array', 'max:3']`. Con `X-Idempotency-Enabled: true`: `['required', 'array', 'min:1', 'max:3']` | | advanced_options.references.* | Cada referencia. El conjunto completo es la llave de idempotencia | `['required', 'string', 'max:50']` | | advanced_options.result_urls | A dónde vuelve el cliente y a dónde te notificamos | `['nullable', 'array']` | | advanced_options.result_urls.approved | Retorno de una transacción aprobada. También se acepta la clave `Aprobada` | `['nullable', 'url']` | | advanced_options.result_urls.rejected | Retorno de una transacción rechazada. También se acepta la clave `Rechazada` | `['nullable', 'url']` | | advanced_options.result_urls.pending | Retorno de una transacción pendiente. También se acepta la clave `Pendiente` | `['nullable', 'url']` | | advanced_options.result_urls.webhook | A dónde te notificamos cada cambio de estado. Debe aceptar `POST`. [Ver más](/webhook-transaction) | `['nullable', 'url']` | | advanced_options.delivery_service | Servicio de envío | `['nullable']` | | advanced_options.delivery_service.type | `Gratis` o `Con Valor`. Ver [enumeraciones](/resources) | `['required_with:advanced_options.delivery_service', 'in:Gratis,Con Valor']` | | advanced_options.delivery_service.value | Valor del envío, que se suma al monto. Obligatorio cuando el tipo es `Con Valor` | `['required_with:advanced_options.delivery_service', 'required_if:advanced_options.delivery_service.type,Con Valor', 'numeric', 'decimal:0,2']` | | advanced_options.request_address_delivery | Pedirle la dirección de envío al cliente en el checkout | `['nullable', 'boolean']` | | advanced_options.has_all_payment_methods | `true` para ofrecer todos los medios habilitados en tu comercio, sin listarlos | `['nullable', 'boolean']` | | advanced_options.payment_methods | Qué medios de pago ofreces. Debe traer al menos uno, y todos deben estar habilitados en tu comercio. Ver [métodos de pago](/resources#metodos-de-pago) | `['sometimes', 'bail']` | | advanced_options.payment_methods.credit | Franquicias de tarjeta a aceptar | `['sometimes', 'nullable', 'array']` | | advanced_options.payment_methods.debit | Medios débito a aceptar | `['sometimes', 'nullable', 'array']` | | advanced_options.payment_methods.pse | Bancos PSE a aceptar | `['sometimes', 'nullable', 'array']` | | advanced_options.payment_methods.cash | Puntos de recaudo en efectivo a aceptar | `['sometimes', 'nullable', 'array']` | | advanced_options.discount | Descuento sobre el monto | `['nullable']` | | advanced_options.discount.before_on | Hasta qué fecha aplica el descuento | `['required_with:advanced_options.discount', 'date', 'after_or_equal:today']` | | advanced_options.discount.type | `value` o `percentage`. Ver [enumeraciones](/resources) | `['required_with:advanced_options.discount', 'in:value,percentage']` | | advanced_options.discount.value | Valor del descuento, según el tipo. Un porcentaje no puede pasar de 100, ni un valor superar el monto | `['required_with:advanced_options.discount', 'numeric', 'decimal:0,2']` | | advanced_options.cash_expired_period | Cuánto dura el cupón de pago en efectivo, junto con `cash_expired_interval` | `['nullable', 'required_with:advanced_options.cash_expired_interval', 'integer', 'between:1,60']` | | advanced_options.cash_expired_interval | Unidad de esa duración. Ver [frecuencias](/resources#frecuencias-efectivo) | `['nullable', 'required_with:advanced_options.cash_expired_period', 'in:minute,hour,day,week,month,year']` | | advanced_options.has_comments | Pedirle un comentario al cliente en el checkout | `['nullable', 'boolean']` | | advanced_options.comments_label | Qué texto acompaña ese campo de comentario | `['nullable', 'string', 'max:100']` | | advanced_options.credibanco_gateway | Tus propios códigos de terminal Credibanco, si no operas por agregador | `['nullable', 'array']` | | advanced_options.credibanco_gateway.code | Código único de comercio en Credibanco | `['required_with:advanced_options.credibanco_gateway', 'string', 'min:3', 'max:15']` | | advanced_options.credibanco_gateway.terminal | Código de terminal en Credibanco | `['required_with:advanced_options.credibanco_gateway', 'string', 'min:3', 'max:15']` | | advanced_options.redeban_gateway | Tus propios códigos de terminal Redeban | `['nullable', 'array']` | | advanced_options.redeban_gateway.code | Código único de comercio en Redeban | `['required_with:advanced_options.redeban_gateway', 'string', 'min:3', 'max:15']` | | advanced_options.redeban_gateway.terminal | Código de terminal en Redeban | `['required_with:advanced_options.redeban_gateway', 'string', 'min:3', 'max:15']` | | advanced_options.pse_gateway | Tus propios códigos PSE | `['nullable', 'array']` | | advanced_options.pse_gateway.code | Código único de comercio en PSE | `['required_with:advanced_options.pse_gateway', 'string', 'min:3', 'max:15']` | | advanced_options.pse_gateway.nit | NIT de tu comercio | `['required_with:advanced_options.pse_gateway', 'string', 'min:8', 'max:11']` | ## Datos del pagador [#datos-del-pagador] Objeto **opcional** que solo aplica cuando `payment.checkout_type` es `redirect`. Si lo envías, los datos del pagador llegan precargados en el checkout y el pagador puede modificarlos antes de pagar. Si no lo envías, el checkout pide los datos en blanco. Si lo envías junto a `checkout_type: api` la solicitud es rechazada. Todos los campos son opcionales: puedes enviar solo los que tengas (por ejemplo únicamente `name` y `email`). Estos pares se exigen juntos: `identification_type` con `id_number`, `dialling_code` con `cellphone` y `country` con `state`. | Nombre del campo | Descripción | Reglas | | - | - | - | | customer_information | Datos del pagador para precargar el checkout. **Prohibido** cuando `checkout_type` es `api` | `['nullable', 'array', 'prohibited_unless:payment.checkout_type,redirect']` | | customer_information.name | Nombre completo del pagador. No puede ser un número de tarjeta | `['nullable', 'string', 'min:5', 'max:255']` | | customer_information.email | Correo del pagador | `['nullable', 'email:rfc,strict', 'max:255']` | | customer_information.identification_type | Tipo de documento. Ver [enumeraciones](/resources) | `['nullable', 'required_with:customer_information.id_number', 'in:CC,CE,TI,PPT,DNI,NIT,Pasaporte,Otro']` | | customer_information.id_number | Número de documento. El formato depende del tipo: CC 6–10 dígitos, TI 10–11, CE alfanumérico 6–15, PPT 7–15 dígitos, DNI alfanumérico 6–20, NIT 9–10 dígitos, Pasaporte alfanumérico 6–20, Otro máximo 30 | `['nullable', 'required_with:customer_information.identification_type']` | | customer_information.dialling_code | Indicativo del país, con `+` | `['nullable', 'required_with:customer_information.cellphone', 'regex:/^\+\d{1,3}$/i']` | | customer_information.cellphone | Celular, solo dígitos | `['nullable', 'required_with:customer_information.dialling_code', 'numeric', 'digits_between:5,15']` | | customer_information.country | País en ISO3. Ver [lista de países](/resources) | `['nullable', 'required_with:customer_information.state', 'string', 'in:COL,USA,MEX,...']` | | customer_information.state | Departamento. Si `country` es `COL` debe estar en la [lista de departamentos](/resources#lista-de-departamentos); en otros países es texto libre | `['nullable', 'string', 'max:100']` | | customer_information.city | Ciudad. Usa el nombre exacto de la [lista de ciudades](/resources#lista-de-ciudades) para que coincida con el checkout | `['nullable', 'string', 'max:100']` | | customer_information.address_1 | Dirección de residencia. No puede ser un número de tarjeta | `['nullable', 'string', 'min:5', 'max:100']` | :::note Departamento y ciudad deben coincidir con `/api/v1/resources/get-departments` y `/api/v1/resources/get-cities/{department}`. Un departamento que no esté en esa lista rechaza la solicitud. ::: ## Metadata [#metadata] `payment.metadata` es un objeto de pares clave-valor donde guardas tus propias referencias (id de pedido, de usuario, canal…), al estilo de Stripe. No lo usamos para nada: te lo devolvemos. | Límite | Valor | | - | - | | Claves | Hasta 50 | | Formato de la clave | 1 a 40 caracteres, `[A-Za-z0-9_-]` | | Valor | Texto o número, hasta 500 caracteres. **Se guarda como texto** (`42` vuelve como `"42"`) | ```json { "payment": { "description": "Orden 12345", "amount": 20000, "currency_type": "COP", "checkout_type": "redirect", "metadata": { "order_id": "ORD-12345", "user_id": 42 } }, "office": 1 } ``` Lo recibes de vuelta en el [webhook de la transacción](/webhook-transaction#rechazos-metadata), en `checkout.payment_referenceable.metadata`. Un valor vacío (`""` o `null`) no se guarda. Si no cumple los límites, la respuesta es un `422` con `error.code: "validation_failed"` y `error.param: "payment.metadata"`. ## Idempotencia [#idempotencia] La idempotencia es **opcional**. Si no envías el header, el endpoint se comporta igual que antes. Sirve para que, si por un timeout, un doble clic o un reintento automático vuelves a llamar `generate-payment` con las mismas referencias, la API **no cree otro pago**. En su lugar responde con el pago ya generado. ### Header | Header | Descripción | Reglas | | - | - | - | | X-Idempotency-Enabled | Activa la búsqueda de un pago previo con las mismas referencias | Opcional. Valores aceptados: `true`, `1`, `yes`, `on` | ### Cómo funciona 1. Envía `X-Idempotency-Enabled: true`. 2. Envía `advanced_options.references` con al menos una referencia (máximo 3). 3. La API busca, en el mismo comercio y ambiente (pruebas o producción), un pago generado en las **últimas 24 horas** con exactamente las mismas referencias y en el mismo orden. 4. Si lo encuentra, responde ese mismo `payment_id` (y `url` si el checkout es `redirect`). 5. Si no lo encuentra, crea el pago normalmente. :::note Sin el header, las referencias solo sirven para trazabilidad y consultas. No evitan un cobro o pago duplicado. ::: :::caution El conjunto completo de referencias es la llave. `["ORD-1"]` y `["ORD-1", "EXTRA"]` se tratan como operaciones distintas. Pasadas 24 horas, la misma llave puede crear un pago nuevo. ::: ### Respuestas al reintentar | checkout_type | Primera respuesta | Reintento con la misma llave | | - | - | - | | redirect | `saved`, `payment_id`, `url` | Mismo `payment_id` y misma `url` | | api | `saved`, `payment_id`, `token` | Solo `saved` y `payment_id` (el token original no se vuelve a devolver; guárdalo en la primera respuesta) | Si otra solicitud con las mismas referencias se está procesando al mismo tiempo, la API puede responder `429` para que reintentes unos segundos después. ### Ejemplo con idempotencia ```bash curl -X POST\ "/api/v1/payment/generate-payment"\ -H 'Authorization: Bearer ACCESS_TOKEN' \ -H "Content-type: application/json" \ -H "X-Idempotency-Enabled: true" \ -d '{ "payment": { "description": "Orden 12345", "amount": 20000, "currency_type": "COP", "checkout_type": "api" }, "advanced_options": { "references": [ "ORD-12345", "FAC-67890" ] }, "office": 1 }' ``` ## Ejemplos [#ejemplos] ### Solicitud en modalidad `redirect` `POST /api/v1/payment/generate-payment` Cuerpo de ejemplo: ```json { "payment": { "description": "Test Efipay", "amount": 1000, "currency_type": "COP", "checkout_type": "redirect" }, "advanced_options": { "picture": "https://mi-tienda.com/img/producto.png", "has_comments": false }, "customer_information": { "name": "Juan Pérez", "email": "juan@correo.com", "identification_type": "CC", "id_number": "1020304050", "dialling_code": "+57", "cellphone": "3001234567", "country": "COL", "state": "Antioquia", "city": "Medellín", "address_1": "Calle 10 #43-25" }, "office": 1 } ``` ```bash curl -X POST\ "/api/v1/payment/generate-payment"\ -H 'Authorization: Bearer ACCESS_TOKEN' \ -H "Content-type: application/json" \ -d '{ "payment" : { "description": "Prueba Efipay", "amount": 20000, "currency_type": "COP", "checkout_type": "redirect" }, "advanced_options": { "picture": "https://mi-tienda.com/img/producto.png", "limit_date": "2030-12-24", "references": [ "123455678", "1234556789", "1234556780" ], "result_urls": { "approved": "https://mi-tienda.com/gracias", "rejected": "https://mi-tienda.com/error", "pending": "https://mi-tienda.com/procesando" }, "delivery_service": { "type": "Con Valor", "value": 2000 }, "request_address_delivery": true, "discount": { "before_on": "2030-12-07", "type": "value", "value": "2000" }, "has_comments": true, "comments_label": "Aqui tu comentario", "redeban_gateway": { "code": "12349876", "terminal": "DRA12335" } }, "customer_information": { "name": "Juan Pérez", "email": "juan@correo.com", "identification_type": "CC", "id_number": "1020304050", "dialling_code": "+57", "cellphone": "3001234567", "country": "COL", "state": "Antioquia", "city": "Medellín", "address_1": "Calle 10 #43-25" }, "office": 1 }' ``` **Ejemplo de solicitud por api:** `POST /api/v1/payment/generate-payment` Cuerpo de ejemplo: ```json { "payment": { "description": "Prueba alltruismo 2", "amount": 20000, "currency_type": "COP", "checkout_type": "api" }, "advanced_options": { "picture": "https://mi-tienda.com/img/producto.png", "limit_date": "2030-12-24", "references": [ "123455678", "1234556789", "1234556780" ], "result_urls": { "approved": "https://mi-tienda.com/gracias", "rejected": "https://mi-tienda.com/error", "pending": "https://mi-tienda.com/procesando" }, "delivery_service": { "type": "Con Valor", "value": 2000 }, "request_address_delivery": true, "discount": { "before_on": "2030-12-07", "type": "value", "value": "2000" }, "has_comments": true, "comments_label": "Aqui tu comentario" }, "office": 1 } ``` ```bash curl -X POST\ "/api/v1/payment/generate-payment"\ -H 'Authorization: Bearer ACCESS_TOKEN' \ -H "Content-type: application/json" \ -d '{ "payment" : { "description": "Ejemplo generar payment", "amount": 20000, "currency_type": "COP", "checkout_type": "api" }, "advanced_options": { "picture": "https://mi-tienda.com/img/producto.png", "limit_date": "2030-12-24", "references": [ "123455678", "1234556789", "1234556780" ], "result_urls": { "approved": "https://mi-tienda.com/gracias", "rejected": "https://mi-tienda.com/error", "pending": "https://mi-tienda.com/procesando" }, "delivery_service": { "type": "Con Valor", "value": 2000 }, "request_address_delivery": true, "discount": { "before_on": "2030-12-07", "type": "value", "value": "2000" }, "has_comments": true, "comments_label": "Aqui tu comentario", "redeban_gateway": { "code": "12349876", "terminal": "DRA12335" } }, "office": 1 }' ``` ## Respuestas [#respuestas] ### Con `checkout_type: redirect` :::tip Respuesta satisfactoria ::: Código de respuesta: 200 ```json { "saved": true, "payment_id": "9dc12b03-5833-496a-83e6-4dfb8eb2570b", "url": "https://sag.efipay.co/Checkout/PaymentGateway/9dc12b03-5833-496a-83e6-4dfb8eb2570b?signature=e7e33380957f87b98dab2353ccb7b3c5aed4387c44297fdefe03594b2ae17d79" } ``` Redirige a tu cliente a `url`. La firma va incluida: no la modifiques ni le agregues parámetros, o el link deja de ser válido. Si enviaste `advanced_options.limit_date`, el link **expira** en esa fecha. ### Con `checkout_type: api` :::tip Respuesta satisfactoria ::: Código de respuesta: 200 ```json { "saved": true, "payment_id": "9dc12b26-dc55-474c-8602-5d9e00af129e", "token": "ZQZ82Ifn5fAuzKL" } ``` No hay `url`: el par `payment_id` + `token` es lo que usas para [procesar el pago desde tu checkout](/checkout-transaction). :::danger El `token` se devuelve **una sola vez**. Guárdalo junto al `payment_id`; en nuestra base solo queda su hash. Si lo pierdes, genera un cobro nuevo. ::: :::note Si reintentas con idempotencia y `checkout_type: api`, la respuesta solo incluye `saved` y `payment_id`. El `token` solo se entrega en la primera creación; guárdalo del lado del comercio. ::: ### Errores :::danger Validación ::: Código de respuesta: 422 ```json { "payment.currency_type": ["El campo payment.currency_type es obligatorio."], "errors": { "payment.currency_type": ["El campo payment.currency_type es obligatorio."] }, "message": "El campo payment.currency_type es obligatorio.", "error": { "type": "invalid_request_error", "code": "validation_failed", "message": "El campo payment.currency_type es obligatorio.", "param": "payment.currency_type" } } ``` :::note Este endpoint devolvía **solo** el mapa de campos en la raíz (`"payment.currency_type": [...]`). Esas claves se conservan, así que si las lees así sigues funcionando; ahora además llegan `message`, `errors` y el [objeto `error`](/error-codes#sobre), igual que en el resto de la API. ::: :::caution Si omites `payment.currency_type`, **es el único error que verás**: sin la moneda no se pueden aplicar las demás reglas (los límites de monto dependen de ella). Corrígelo y vuelve a enviar para ver el resto. ::: :::danger Conflicto de idempotencia ::: Código de respuesta: 429 ```json { "message": "Otra solicitud con las mismas referencias está siendo procesada, por favor intenta de nuevo.", "error": { "type": "rate_limit_error", "code": "too_many_requests", "message": "Otra solicitud con las mismas referencias está siendo procesada, por favor intenta de nuevo.", "param": null } } ``` :::caution **Cambio de forma.** Antes `error` era el texto (`{"error": "..."}`); ahora el texto va en `message` y `error` es un objeto. Si leías `error` como string, cambia a `message` o a `error.code`. :::