# Entregas

Todas las respuestas llevan `Cache-Control: no-store`. Los cuerpos son objetos JSON cerrados: una clave desconocida o repetida se rechaza con `400`. Los parámetros de consulta también son cerrados.

## Crear una entrega

`POST /api/v1/deliveries` · permiso `deliveries:write` · encabezado `Idempotency-Key` obligatorio ([idempotencia](idempotencia.html)).

| Campo | Obligatorio | Regla |
| --- | --- | --- |
| `customerName` | sí | 1–120 caracteres, sin caracteres de control. |
| `address` | sí | 1–240 caracteres; admite saltos de línea. Ruta no geocodifica la dirección. |
| `recipientPhone` | no | E.164, por ejemplo `+59899123456`. También acepta un celular uruguayo sin prefijo (`099123456`) y lo guarda como `+59899123456`; las respuestas lo devuelven siempre en E.164. |
| `recipientEmail` | no | Email de quien recibe, para los avisos por email del courier (y como alternativa si el WhatsApp no llega). Hasta 254 caracteres; se guarda sin espacios y en minúsculas. Un email mal formado responde `400`. |
| `instructions` | no | Hasta 280 caracteres. |
| `externalReference` | no | El número de tu pedido: 1–64 caracteres `A-Z a-z 0-9 _ . : # / -`, empezando por letra o dígito. Único por empresa **y comercio**. |
| `scheduledDate`, `windowStart`, `windowEnd` | no | Programación: `YYYY-MM-DD` y `HH:mm` en la hora de la empresa. Requiere que el courier la tenga habilitada. |
| `amountToCollect` | no | Cobro contra entrega, texto decimal con punto (`"1250.00"`), en la moneda de `currency`. |
| `currency` | no | Moneda del cobro: `"UYU"` (pesos uruguayos, el valor si no lo mandás) o `"USD"` (dólares). Con `"USD"` hace falta `amountToCollect`. Ruta **nunca convierte** entre monedas: el repartidor cobra y da el vuelto en esa moneda. Otro valor responde `400 INVALID_CURRENCY`. |
| `shipperId` | no | Id de un comercio activo de la empresa. Con una clave de comercio, se usa el suyo. |
| `zoneId` | no | Id de una zona activa (ver [zonas](#zonas)). |
| `destinationLat`, `destinationLng` | no | Números (grados decimales), los dos o ninguno: el punto exacto de la puerta. Tiene que estar en Uruguay (latitud −35,2 a −30,0, longitud −58,6 a −53,0); si no, `400 VALIDATION_FAILED`. Sin punto, y con la búsqueda de direcciones activa en la cuenta, Ruta puede ponerle uno **aproximado** a los pocos minutos, sólo si encuentra la dirección sin dudas; nunca reemplaza un punto que mandaste. Ver [Direcciones y puntos en el mapa](direcciones-y-mapa.html#entregas-que-llegan-sin-punto). |

Todos los campos son texto o `null`, salvo las coordenadas, que son números. Las reglas son **exactamente las del alta en el panel**: plan, cuenta en solo lectura, programación, cobro, comercio y zona.

> [!NOTE]
> **Cupo mensual.** Si el courier ya usó las entregas de su plan este mes, el pedido **no se rechaza**: se crea igual (`201`) con `quotaHeld: true` y queda retenido, sin repartidor, hasta que el courier tenga cupo (el 1.º del mes o al subir de plan). Entonces sale solo y `quotaHeld` pasa a `false`. No tenés que reintentar nada.

Respuesta `201 Created` con `Location: /api/v1/deliveries/{id}` e `Idempotent-Replayed: false`:

```json
{
  "id": "6f1c1d0e-2f5e-4bb5-9c1a-1c1b1f0e9a77",
  "status": "CREATED",
  "customerName": "Ana Pérez",
  "address": "Av. Italia 1234, apto 5",
  "recipientPhone": "+59899123456",
  "instructions": "Timbre 5",
  "trackingUrl": "https://<tu-courier>/track/0b6d…",
  "scheduledDate": null, "windowStart": null, "windowEnd": null, "scheduleTimeZone": null,
  "amountToCollect": "1250.00",
  "shipperId": null, "zoneId": null,
  "destinationLat": null, "destinationLng": null,
  "attemptNumber": 1, "failureReasonCode": null,
  "createdAt": "2026-10-10T15:04:05.123456Z",
  "startedAt": null, "deliveredAt": null, "cancelledAt": null, "failedAt": null,
  "externalReference": "1042",
  "collectedAmount": null, "collectionMethod": null, "collectedAt": null, "settledAt": null,
  "test": false,
  "recipientEmail": null,
  "quotaHeld": false, "quotaHeldAt": null,
  "currency": "UYU",
  "collectedCurrency": null
}
```

Compartí `trackingUrl` con el destinatario: es la misma página de seguimiento que usa el panel.

Si `externalReference` ya está en otra entrega de la empresa y el mismo comercio, la respuesta es `409 EXTERNAL_REFERENCE_TAKEN` y no se consume cupo. Dos comercios distintos pueden tener el mismo número de pedido.

## Crear en lote

`POST /api/v1/deliveries/batch` · permiso `deliveries:write`. Hasta **100** entregas por llamada (cuerpo de hasta 512 KiB), cada una con su propia `idempotencyKey` dentro del objeto:

```bash
curl -X POST "$RUTA_API/api/v1/deliveries/batch" \
  -H "Authorization: Bearer $RUTA_API_KEY" -H "Content-Type: application/json" \
  -d '{"deliveries": [
        {"idempotencyKey": "pedido-1042", "customerName": "Ana Pérez", "address": "Av. Italia 1234", "externalReference": "1042"},
        {"idempotencyKey": "pedido-1043", "customerName": "Bruno Díaz", "address": "Rivera 2020", "externalReference": "1043"}
      ]}'
```

Cada ítem se crea por separado, en orden: uno que falla **no deshace** a los demás. La respuesta es `200` con un resultado por ítem:

```json
{
  "results": [
    {"index": 0, "status": 201, "idempotencyKey": "pedido-1042", "replayed": false, "delivery": {"id": "…"}, "error": null},
    {"index": 1, "status": 409, "idempotencyKey": "pedido-1043", "replayed": false, "delivery": null,
     "error": {"code": "EXTERNAL_REFERENCE_TAKEN", "detail": "External reference already used by another delivery"}}
  ],
  "created": 1,
  "failed": 1
}
```

Repetir el lote completo con las mismas claves es seguro: los ítems ya creados vuelven con `replayed: true`. Si la plataforma falla a mitad (`5xx`), los ítems anteriores quedaron creados; reintentá el lote igual.

## Consultar y listar

```bash
curl "$RUTA_API/api/v1/deliveries/$ID" -H "Authorization: Bearer $RUTA_API_KEY"
curl "$RUTA_API/api/v1/deliveries?limit=50" -H "Authorization: Bearer $RUTA_API_KEY"
curl "$RUTA_API/api/v1/deliveries?limit=50&cursor=<nextCursor>" -H "Authorization: Bearer $RUTA_API_KEY"
curl "$RUTA_API/api/v1/deliveries?externalReference=1042" -H "Authorization: Bearer $RUTA_API_KEY"
```

Permiso `deliveries:read`. El listado devuelve `{"deliveries": [...], "nextCursor": "..." | null}`, de la entrega más nueva a la más vieja. `limit` va de 1 a 100 (50 por defecto); seguí `nextCursor` hasta recibir `null`. Un id de otra empresa (o de otro comercio, con una clave de comercio), inexistente o mal formado responde igual: `404 NOT_FOUND`.

## Corregir antes de que salga

`PATCH /api/v1/deliveries/{id}` · permiso `deliveries:write`. Sólo mientras la entrega está `CREATED` o `ASSIGNED`; después responde `409 DELIVERY_STATE_INVALID`.

| Campo | Regla |
| --- | --- |
| `customerName`, `address` | Ausente o `null` conserva el valor; vacío es `400`. |
| `recipientPhone`, `recipientEmail`, `instructions`, `amountToCollect`, `zoneId`, `externalReference` | Ausente o `null` conserva; `""` borra; un valor lo reemplaza. |
| `currency` | Ausente o `null` conserva la moneda; `"UYU"` o `"USD"` la cambia (con `"USD"` la entrega tiene que tener monto). Borrar el monto vuelve a pesos. |
| `destinationLat`, `destinationLng` | Si enviás alguna, el par reemplaza el punto; las dos en `null` lo borran. |

La programación y el comercio no se cambian por esta vía. Un cuerpo vacío es `400`.

<div class="tabs">

```bash
curl -X PATCH "$RUTA_API/api/v1/deliveries/6f1c1d0e-2f5e-4bb5-9c1a-1c1b1f0e9a77" \
  -H "Authorization: Bearer $RUTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "address": "Av. Italia 1234, apto 7",
       "instructions": ""
     }'
```

```js
// Node.js 18+ (fetch incluido)
const response = await fetch(`${process.env.RUTA_API}/api/v1/deliveries/6f1c1d0e-2f5e-4bb5-9c1a-1c1b1f0e9a77`, {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.RUTA_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "address": "Av. Italia 1234, apto 7",
    "instructions": ""
  }),
})
if (!response.ok) throw new Error(`Ruta respondió ${response.status}`)
const delivery = await response.json()
```

```python
# Python 3 con requests (pip install requests)
import os

import requests

response = requests.patch(
    f"{os.environ['RUTA_API']}/api/v1/deliveries/6f1c1d0e-2f5e-4bb5-9c1a-1c1b1f0e9a77",
    headers={
        "Authorization": f"Bearer {os.environ['RUTA_API_KEY']}",
    },
    json={
        "address": "Av. Italia 1234, apto 7",
        "instructions": "",
    },
    timeout=30,
)
response.raise_for_status()
delivery = response.json()
```

```php
<?php // PHP 8 con la extensión curl
$ch = curl_init(getenv('RUTA_API') . '/api/v1/deliveries/6f1c1d0e-2f5e-4bb5-9c1a-1c1b1f0e9a77');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'PATCH',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('RUTA_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'address' => 'Av. Italia 1234, apto 7',
        'instructions' => '',
    ], JSON_UNESCAPED_UNICODE),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) throw new RuntimeException("Ruta respondió $status");
$delivery = json_decode($body, true);
```

</div>

## Cancelar

`POST /api/v1/deliveries/{id}/cancel` · permiso `deliveries:write` · sin cuerpo. Sólo `CREATED` o `ASSIGNED` (si ya salió, `409 DELIVERY_STATE_INVALID`). Es idempotente: cancelar una entrega ya cancelada responde `200` con la misma entrega y el mismo `cancelledAt`.

## Estados

| `status` | Significa |
| --- | --- |
| `CREATED` | Cargada, sin repartidor. |
| `ASSIGNED` | Asignada a un repartidor. |
| `OUT_FOR_DELIVERY` | El repartidor salió a entregar. Si la inició por error puede volver a `ASSIGNED` con el mismo `attemptNumber` (eventos `delivery.status_changed` y `delivery.assigned`). |
| `DELIVERED` | Entregada. Si había cobro, `collectedAmount`, `collectionMethod` y `collectedAt` dicen lo declarado. |
| `FAILED` | No se pudo entregar (`failureReasonCode`). Puede reintentarse: vuelve a `CREATED` o `ASSIGNED` con `attemptNumber` + 1. |
| `CANCELLED` | Cancelada. |

Ver el [ciclo de vida completo](objeto-entrega.html#ciclo-de-vida), con el evento de cada paso, y [cada campo](objeto-entrega.html#campos) de la respuesta.

`settledAt` se completa cuando el courier rinde el cobro al comercio. La API nunca muestra la identidad ni la ubicación del repartidor, ni notas internas.

## Zonas

`GET /api/v1/zones` · permiso `deliveries:read` → `{"zones": [{"id": "…", "name": "Centro", "areas": "Ciudad Vieja, Cordón"}]}`: las zonas activas de la empresa, para elegir `zoneId`; `areas` son los barrios que cubre, tal como los escribió el courier (puede ser vacío).
