> ## Documentation Index
> Fetch the complete documentation index at: https://ayuda.gudink.com/llms.txt
> Use this file to discover all available pages before exploring further.

# API: errores, límites y versiones

> La tabla completa de códigos de error de la API de Gudink con qué hacer en cada caso, un ejemplo real de lo que trae cada error, la política de reintentos con números, los límites de uso y cómo se versiona la API.

## Cómo se ve un error

Toda respuesta de error tiene la misma forma:

```json theme={null}
{
  "error": {
    "code": "quote_expired",
    "message": "La cotización de envío no existe o venció. Pedí una nueva.",
    "details": {}
  }
}
```

* `code` es estable: **decidí por `code` y por el status HTTP**.
* `message` es un texto en castellano para una persona. Puede cambiar sin aviso: no lo uses en tu lógica.
* `details` es siempre un objeto, vacío (`{}`) si no hay nada que agregar. Su forma depende de `code`: ver [Qué trae details](#que-trae-details). La especificación la publica código por código, en los esquemas `Error_<código>`.

## Respuestas que no son de la API

Toda respuesta de la API es JSON, y todo error trae el sobre `{ "error": { ... } }`. Entre tu servidor y la API hay infraestructura de red que puede responder por su cuenta, sin ese sobre. La API nunca responde 502 ni 504. **Los códigos de la tabla de abajo sólo cuentan cuando está el sobre.**

| Respuesta | Qué es | Qué hacer |
| - | - | - |
| Un 5xx que no es JSON, o sin el sobre | Un error de red | Reintentá igual que un timeout (al crear un pedido, con el mismo `external_id` y el mismo cuerpo) |
| Un 403 que no es JSON (un texto o una página, por ejemplo `error code: 1010`) | Un bloqueo de la red, no de la API: tu clave no tiene nada que ver | Casi siempre es el `User-Agent` por defecto de tu librería: mandá uno propio (por ejemplo `MiTienda/1.0 (+https://mitienda.com)`) y repetí. Si sigue, escribinos a [soporte@gudink.com](mailto:soporte@gudink.com) con la hora y tu IP. No lo trates como `api_access_disabled` |
| Cualquier otra respuesta sin el sobre | Un error de red | Tratala como un timeout |

## Tabla de errores

| Status | `error.code` | Qué hacer | ¿Reintentar? |
| - | - | - | - |
| 400 | `validation_error` | Corregí los campos que marca `details.fields` | No, hasta corregir |
| 400 | `province_required` | La API no reconoce el código postal: pedile la provincia a tu comprador y repetí con ella | Sí, con la provincia |
| 400 | `pickup_point_required` | La opción elegida es un retiro en sucursal: mandá `shipping.pickup_point_id` en lugar de `shipping_address`. Ver [Retiro en sucursal](/api/pedidos#retiro-en-sucursal) | Sí, con la sucursal |
| 400 | `invalid_body` | El cuerpo no es JSON válido | No, hasta corregir |
| 401 | `invalid_api_key` | Revisá la cabecera `Authorization` y que la clave no esté revocada ni sea de otro entorno | No |
| 403 | `api_access_disabled` | Tu cuenta no tiene la API habilitada: [pedí acceso](/api/introduccion#como-pedir-acceso) | No |
| 403 | `account_under_review` | Escribinos a [soporte@gudink.com](mailto:soporte@gudink.com) | No |
| 403 | `billing_data_required` | Completá en tu panel lo que dice `details.missing_fields` | Después de completar |
| 403 | `forbidden` | Reservado: hoy ninguna ruta lo devuelve. Si llega, tratalo como un problema de permisos de la cuenta | No |
| 404 | `not_found` | El recurso no existe o no es de tu cuenta. También si la ruta no existe o la API está apagada | No |
| 409 | `external_id_conflict` | Ese `external_id` ya se usó con otro cuerpo. El pedido existente está en `details.order_id` | No |
| 409 | `order_in_progress` | El pedido con ese `external_id` se está terminando de crear | Sí, la misma llamada, después de `Retry-After` |
| 409 | `quote_expired` | La cotización venció o no existe: cotizá de nuevo | Sí, con la cotización nueva y el mismo `external_id` |
| 409 | `quote_mismatch` | La cotización no coincide con el pedido en código postal, líneas, `service_code` o provincia | Sí, cotizando con las líneas del pedido |
| 409 | `pickup_point_mismatch` | `shipping.pickup_point_id` no es una sucursal de esa opción en esa cotización, o la opción elegida es a domicilio | Sí, con una sucursal de esa opción |
| 409 | `product_archived` | Algún producto está archivado: cuáles, en `details.product_ids` | No |
| 409 | `variant_not_orderable` | Alguna variante no se puede pedir de ese producto: cuáles, en `details.items` | No |
| 409 | `no_shipping_options` | No hay envío a ese código postal: ni a domicilio ni retiro en sucursal | No |
| 409 | `out_of_stock` | Sin stock suficiente de las líneas de `details.items`. No queda ningún pedido en la API. Ver [Stock y reservas](/api/pedidos#stock-y-reservas) | Más tarde, con el mismo `external_id`. Nunca en bucle |
| 409 | `too_many_unpaid_orders` | Tenés 20 pedidos por API sin pagar: pagá o cancelá alguno | Después de pagar o cancelar |
| 409 | `cancel_requires_panel` | El pedido ya no está esperando tu pago: cancelalo desde tu panel | No |
| 409 | `invalid_transition` | Sólo en el [entorno de pruebas](/api/entorno-de-pruebas): ese paso de la simulación no corresponde al estado del pedido (`details.status`) | Con el paso que sigue |
| 409 | `conflict` | Conflicto momentáneo al crear | Sí, con el mismo `external_id` |
| 413 | `payload_too_large` | El cuerpo pasa los 64 KB (`details.limit_bytes`) | No, hasta achicarlo |
| 429 | `rate_limited` | Pasaste un límite | Sí, después de `Retry-After` |
| 500 | `internal_error` | Error nuestro | Sí. Al crear un pedido, siempre con el mismo `external_id` |
| 503 | `service_unavailable` | No disponible por un rato | Sí, después de `Retry-After` |

Si recibís un `code` que no está en la tabla (con el sobre), decidí por el status HTTP: un 4xx no se reintenta sin cambiar algo, un 5xx sí. Sin el sobre, ver [Respuestas que no son de la API](#respuestas-que-no-son-de-la-api).

## Qué trae details

Un ejemplo real de cada código que trae contenido. Todos los códigos que no figuran acá traen `details` vacío (`{}`). El `message` de los ejemplos es ilustrativo: nunca decidas por él.

### validation\_error

`details.fields` es un objeto: la clave es la ruta del campo y el valor es su mensaje (el primero de ese campo).

* Las rutas son las del cuerpo o de la query, con punto y el índice de la lista: `shipping_address.number`, `items.0.quantity`.
* Un error que no es de un campo en particular va bajo la clave `_`.

```json theme={null}
{
  "error": {
    "code": "validation_error",
    "message": "(texto para una persona)",
    "details": {
      "fields": {
        "shipping_address.number": "Campo obligatorio.",
        "items.0.quantity": "quantity va de 1 a 20."
      }
    }
  }
}
```

### province\_required

Un solo campo en `details.fields`: `province` al cotizar, `shipping_address.province` al crear el pedido. Se reconoce por el `code`, no hace falta mirar `details`.

```json theme={null}
{
  "error": {
    "code": "province_required",
    "message": "(texto para una persona)",
    "details": {
      "fields": {
        "province": "El código postal no está en nuestra tabla: mandá también la provincia."
      }
    }
  }
}
```

### out\_of\_stock

Las líneas sin stock suficiente, por su posición en `items` (desde 0).

```json theme={null}
{
  "error": {
    "code": "out_of_stock",
    "message": "(texto para una persona)",
    "details": {
      "items": [{ "index": 0, "product_id": 812, "variant_id": 4410 }]
    }
  }
}
```

### variant\_not\_orderable

La misma forma que `out_of_stock`: las líneas cuya variante no se puede pedir de ese producto.

```json theme={null}
{
  "error": {
    "code": "variant_not_orderable",
    "message": "(texto para una persona)",
    "details": {
      "items": [{ "index": 1, "product_id": 812, "variant_id": 4411 }]
    }
  }
}
```

### billing\_data\_required

Lo que falta completar en el panel, con los mismos nombres que `missing_billing_fields` de `GET /me`: `cuit`, `razon_social`, `condicion_fiscal`, `phone`.

```json theme={null}
{
  "error": {
    "code": "billing_data_required",
    "message": "(texto para una persona)",
    "details": { "missing_fields": ["cuit", "phone"] }
  }
}
```

### product\_archived

Los `product_id` archivados.

```json theme={null}
{
  "error": {
    "code": "product_archived",
    "message": "(texto para una persona)",
    "details": { "product_ids": [812] }
  }
}
```

### external\_id\_conflict

El pedido que ya tiene ese `external_id`. Consultalo con `GET /orders/:id`.

```json theme={null}
{
  "error": {
    "code": "external_id_conflict",
    "message": "(texto para una persona)",
    "details": { "order_id": 1043 }
  }
}
```

### order\_in\_progress

La misma forma: el pedido que se está terminando de crear. Viene con la cabecera `Retry-After`.

```json theme={null}
{
  "error": {
    "code": "order_in_progress",
    "message": "(texto para una persona)",
    "details": { "order_id": 1043 }
  }
}
```

### rate\_limited

Los segundos a esperar. Es el mismo número que la cabecera `Retry-After`.

```json theme={null}
{
  "error": {
    "code": "rate_limited",
    "message": "(texto para una persona)",
    "details": { "retry_after_seconds": 42 }
  }
}
```

### too\_many\_unpaid\_orders

El tope de pedidos por API sin pagar a la vez.

```json theme={null}
{
  "error": {
    "code": "too_many_unpaid_orders",
    "message": "(texto para una persona)",
    "details": { "max_unpaid_orders": 20 }
  }
}
```

### payload\_too\_large

El tope del cuerpo, en bytes.

```json theme={null}
{
  "error": {
    "code": "payload_too_large",
    "message": "(texto para una persona)",
    "details": { "limit_bytes": 65536 }
  }
}
```

### invalid\_transition

Sólo en el entorno de pruebas: el estado actual del pedido y el paso que pediste simular.

```json theme={null}
{
  "error": {
    "code": "invalid_transition",
    "message": "(texto para una persona)",
    "details": { "status": "pending_payment", "event": "ship" }
  }
}
```

## Reintentos

**Qué reintentar:** un error de red o un timeout (también un 5xx que no es JSON, ver [Respuestas que no son de la API](#respuestas-que-no-son-de-la-api)), 500 `internal_error`, 503 `service_unavailable`, 429 `rate_limited`, 409 `conflict` y 409 `order_in_progress`. Nada más: el resto de los 4xx no se arregla repitiendo la misma llamada.

Una sola regla, con un tope que depende de quién está esperando:

| Regla | Número |
| - | - |
| Nunca antes de `Retry-After` | Si vino, esperá por lo menos eso: reintentar antes es otro 429 seguro |
| Espera, si no vino `Retry-After` | 1 segundo, después 2, después 4 |
| Intentos por operación | Hasta 4: el original más 3 reintentos |
| Timeout de `POST /orders` | 30 segundos |

* **Adentro de un checkout** (hay un comprador esperando): esperá dentro del pedido sin pasar de 30 segundos por espera. **Si `Retry-After` pide más de 30 segundos**, no esperes: guardá el pedido como pendiente y seguí con la [receta de abajo](#receta-cuando-te-quedaste-sin-intentos), en segundo plano.
* **En segundo plano** (sincronizar el catálogo, la receta de pendientes, ponerte al día): esperá lo que pida `Retry-After`, sea lo que sea, antes del próximo intento. Un 429 de un límite por minuto pide como mucho 60 segundos; uno del límite diario puede pedir horas: reprogramá el trabajo para ese momento en vez de dejar un proceso esperando.
* **Al crear un pedido, reintentá siempre con el mismo `external_id` y el mismo cuerpo.** Es lo único que garantiza que no se duplique. Si el timeout se cumple antes de la respuesta, no sabés si el pedido se creó: reintentá igual.

Qué respuestas traen la cabecera `Retry-After` (en segundos) y cuánto puede pedir:

| Caso | Orden de magnitud |
| - | - |
| 409 `order_in_progress` | 2 segundos. Puede repetirse durante el primer minuto del alta |
| 429 de un límite por minuto | Hasta 60 segundos |
| 429 del límite diario de altas | Hasta 24 horas |
| 429 por demasiados intentos fallidos seguidos con claves inválidas | Minutos |
| 503 `service_unavailable` | Minutos (300 segundos si es `GET /llms.txt`) |

`Retry-After` es la única cabecera de respuesta que declara el contrato: las respuestas no traen cabeceras de cupo restante (`RateLimit-*`). Para frenar a tiempo, medí tu ritmo contra la tabla de [Límites](#limites).

### Receta cuando te quedaste sin intentos

Si se acabaron los intentos y no sabés si el pedido se creó:

1. Guardá el pedido en tu sistema como pendiente, **con el cuerpo exacto que mandaste**.
2. Antes de reintentar, consultá `GET /orders?external_id=<tu id>`.
3. Si `data` trae un pedido, ya está creado: usá ése.
4. Si `data` viene vacío, mandá de nuevo el mismo cuerpo con el mismo `external_id`.

## Límites

| Qué | Límite |
| - | - |
| Lecturas (`GET`) | 120 por minuto por cuenta |
| Cotizar envío | 30 por minuto por cuenta |
| Crear pedido | 10 por minuto y 300 por día por cuenta |
| Cancelar | 30 por minuto por cuenta |
| Simular un paso (sólo en pruebas) | 30 por minuto por cuenta |
| "Mandar prueba" de un destino de avisos | 5 por minuto |
| Tamaño del cuerpo | 64 KB |
| Líneas por pedido | 20 |
| Unidades | 20 por línea, 100 por pedido |
| Pedidos por API sin pagar a la vez | 20 |
| Resultados por página | 25 como máximo |
| Claves activas | 5 por cuenta |
| Destinos de avisos | 3 por cuenta |
| Vigencia de una cotización de envío | 24 horas |
| Vencimiento de un pedido sin pagar | 72 horas |

* Los límites son por cuenta (todas tus claves suman juntas) y por ventana fija: un minuto, o un día para el tope diario de altas.
* **Cuenta toda solicitud con una clave válida**, también las que terminan en error (400, 404, 409) y los reintentos que devuelven `200`. Un bucle que reintenta un 409 de negocio se come el cupo.
* Al pasarte recibís 429 `rate_limited` con la cabecera `Retry-After` (segundos) y el mismo número en `details.retry_after_seconds`. No reintentes antes de eso (ver [Reintentos](#reintentos)).
* Las respuestas no traen cabeceras de cupo restante (`RateLimit-*`): medí tu ritmo con esta tabla.
* Bajar las imágenes de los productos no cuenta para estos límites.

Si tu operación necesita límites más altos, escribinos a [hola@gudink.com](mailto:hola@gudink.com) contando tu caso.

## Versiones

* La URL base (`/api/v1`) es parte del contrato y no cambia.
* Dentro de `v1` sólo hay cambios que suman: campos nuevos en las respuestas, códigos de error nuevos, eventos nuevos, valores nuevos de cualquier campo de lista cerrada (`status`, `hold_reason`, `cancel_reason`, `product_type`, `payment.status`, `tracking.carrier`, el `type` de un aviso).
* **Tu código tiene que ignorar los campos que no conoce** y no romperse ante un valor nuevo. Tratá un `status` desconocido como "en curso", un `cancel_reason` desconocido como `null`, y respondé 2xx a un evento desconocido.
* Los `enum` de la especificación son los valores de hoy. Si validás respuestas contra la especificación, que un valor nuevo no te haga descartar la respuesta: tratalo como dice cada página ("en curso" para un estado, 2xx y descartar para un aviso). El `type` de un aviso ya figura abierto en la especificación (texto, con los de hoy en `examples`).
* Un cambio que rompe (sacar o renombrar un campo, cambiar un tipo o un significado) sale como `v2`, con 90 días de aviso. Durante ese plazo `v1` sigue funcionando.
* La especificación vigente, en formato OpenAPI, la sirve la propia API en `GET /api/v1/openapi.json`, sin clave. La guía canónica en texto plano, en `GET /api/v1/llms.txt`, también sin clave. Ver [cuál vale si difieren](/api/conectar-con-un-agente#para-el-agente-como-leer-esta-documentacion).

## Checklist antes de pasar a producción

1. La clave de pruebas (`gk_stg_`) está en `GUDINK_API_KEY` en tu servidor y `GUDINK_API_BASE` apunta al entorno de pruebas. Tu cliente HTTP manda un `User-Agent` propio. `GET /me` responde y `billing_complete` es `true`.
2. El catálogo se sincroniza paginando hasta `next_cursor: null`. Guardás cada variante por el par `(product_id, variant_id)`, con su precio, y el `updated_at` del producto. Las imágenes están alojadas en tu sitio.
3. El checkout valida los formatos de [Qué va en cada campo](/api/pedidos#que-va-en-cada-campo) antes de cobrar.
4. El checkout cotiza el envío con el carrito real y muestra las opciones con su `price`. Si responde `province_required`, pide la provincia. Si ofrecés retiro en sucursal, tu comprador elige una sucursal de `pickup_points` antes de pagar.
5. El pedido se crea con un `external_id` único (prefijo de tu instalación más tu número de pedido) y con la `province` de la cotización. Ante un error de red o un 5xx se reintenta con el mismo `external_id` y el mismo cuerpo, según [Reintentos](#reintentos). Después de cada `200` o `201` tu sistema mira `status`: si es `cancelled`, ese pedido no va.
6. Tu sistema maneja `out_of_stock` después de haber cobrado, sin reintentar en bucle.
7. Hay alguien, o algo, que paga los pedidos en tu panel antes de `expires_at` (72 horas), con una alarma propia antes del vencimiento (ver [Pagar es una tarea de todos los días](/api/pedidos#pagar-es-una-tarea-de-todos-los-dias)). En pruebas, con la simulación. Un `order.cancelled` con `cancel_reason: "unpaid_expired"` quiere decir que se te pasó.
8. El destino de avisos está dado de alta, verifica la firma y deduplica por `webhook-id`. "Mandar prueba" llega bien.
9. Con `order.shipped` u `order.tracking_updated` tu comprador recibe el seguimiento. Gudink no le escribe.
10. Todo lo anterior funciona en el entorno de pruebas, con el [recorrido de punta a punta](/api/entorno-de-pruebas#recorrido-de-prueba-de-punta-a-punta). Recién ahí, clave de producción (`gk_live_`) y `GUDINK_API_BASE="https://app.gudink.com/api/v1"`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.