DovExpress — Guía de Integración API Pública & Webhook (ES)
Audiencia: socios/clientes externos que integran con DovExpress para inyección de órdenes, tracking y prueba de entrega (POD). Última revisión contra el código fuente:
api/src(controladoresapi.ts,orders.ts,clients.ts,status.ts,drivers.ts; servicioswebhooks.ts,s3.ts) y el catálogoStatusen vivo.
Todo lo de abajo está en producción salvo que se marque explícitamente como [PROPUESTO] (una brecha planificada — no dependas de ello todavía).
Spec OpenAPI 3.1 — esta API también está descrita en una especificación formal:
- Referencia interactiva: https://docs.dovexpresscr.com/reference/ (probá los endpoints y mirá los esquemas en el navegador).
- Spec cruda: https://docs.dovexpresscr.com/openapi.yaml — importala en Postman o Insomnia, o generá clientes con
openapi-generator,openapi-typescript, etc.La spec y esta guía se publican desde la misma fuente, así que no se desincronizan.
Contenido
- Resumen
- Inicio rápido
- Autenticación
- Endpoints
- Webhooks
- Errores
- Catálogo de estados
- Zona horaria
- Prueba de entrega (POD)
- Ciclo de vida de estados & roadmap
- Referencia rápida
1. Resumen
DovExpress expone una pequeña API REST para clientes. Un cliente es un registro en la tabla Clients identificado por un api_key único. Como cliente podés:
- Crear órdenes de entrega.
- Consultar una orden (por id interno, o por tu referencia / código DovExpress) con su historial completo de tracking.
- Recibir un
POSTpor webhook en cada cambio de estado, una vez que registres una URL de webhook.
El ciclo de vida de la orden se rige por un catálogo de estados compartido (45 estados en producción). Los cambios de estado los producen las operaciones de DovExpress (bodega, distribuidor, app del conductor, panel admin) y se te envían en tiempo real.
Vos ──POST /api/orders/create──▶ DovExpress
│
las operaciones mueven la orden entre estados
│
Tu webhook ◀──POST {data}───────────┘ (cambio de estado, incl. POD en Entregado)2. Inicio rápido
Una integración funcional son cuatro pasos:
- Obtené tu
api-keyde DovExpress (tu única credencial — mantenela secreta). - Registrá tu webhook para recibir actualizaciones de estado:bash
curl -X PUT https://api.dovexpresscr.com/api/webhook \ -H "api-key: $DOV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://tu-app.example.com/dovexpress/webhook" }' - Creá una orden:bash
curl -X POST https://api.dovexpresscr.com/api/orders/create \ -H "api-key: $DOV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reference_id": "CR0256301601", "contactDetails": { "contactName": "Juan Perez", "contactPhone": "88888888" }, "addressDetails": { "address": "200m norte de la iglesia", "postalCode": "10203" } }' - Seguila — esperá los eventos del webhook, o consultá:bash
curl https://api.dovexpresscr.com/api/orders/reference/CR0256301601 \ -H "api-key: $DOV_API_KEY"
Sincronizá tu catálogo de estados una vez al arrancar con
GET /api/statuses(§4.4) y mapeá por el código DOV — nunca hardcodees códigos numéricos.
3. Autenticación
URL base de producción:
https://api.dovexpresscr.comCredencial: toda solicitud debe enviar el header:
api-key: <TU_API_KEY_DE_CLIENTE>
La API resuelve el cliente a partir del api-key, y los dos modos de falla no son el mismo código:
- Header ausente →
400 Bad Requestconmessage: "Required"— lo produce el validador de esquema, ydatatrae la incidencia conpath: ["headers","api-key"]. - Llave desconocida (no corresponde a ningún cliente) →
401 Unauthorizedconmessage: "Client not found".
No hay OAuth/JWT para clientes — el api-key es la única credencial. Mantenela del lado del servidor y en secreto; nunca la expongas en código de navegador o móvil.
Todos los endpoints están acotados a tu cliente: solo podés leer y escribir tus propias órdenes.
4. Endpoints
4.1 Crear orden — POST /api/orders/create
Campos de la solicitud
| Campo | Tipo | Requerido | Notas |
|---|---|---|---|
reference_id | string | – | Tu referencia de tracking. Única por cliente (reuso → 409). |
contactDetails.contactName | string | ✅ | Nombre del destinatario. |
contactDetails.contactPhone | string | – | Teléfono principal. |
contactDetails.contactPhone2 | string | – | Teléfono secundario. |
addressDetails.address | string | ✅ | 1..2000 caracteres. |
addressDetails.postalCode | string | ✅ | |
addressDetails.state / region / city / country | string | – | |
addressDetails.lat / lng | number | – | Coordenadas para ruteo. |
addressDetails.notes | string | – | Notas de entrega para el conductor. |
packageDetails.product | string | – | "Nombre - cant", o separado por comas "A - 1, B - 2". |
packageDetails.quantity | number | – | |
packageDetails.products[] | array | – | Alternativa estructurada: { product, name, quantity }. |
cod | number | – | Monto contra entrega (cash-on-delivery). |
notes | string | – | Notas a nivel de orden. |
Cuerpo de ejemplo
{
"reference_id": "CR0256301601", // opcional; única por cliente
"contactDetails": {
"contactName": "Juan Perez", // requerido
"contactPhone": "88888888",
"contactPhone2": "70000000"
},
"addressDetails": {
"address": "200m norte de la iglesia", // requerido (1..2000 caracteres)
"state": "San José",
"region": "Central",
"city": "Escazú",
"country": "Costa Rica",
"postalCode": "10203", // requerido
"lat": 9.9281,
"lng": -84.0907,
"notes": "Llamar antes"
},
"packageDetails": {
"product": "Shoes - 1", // "Nombre - cant", o "A - 1, B - 2"
"quantity": 1,
"products": [
{ "product": "SKU123", "name": "Shoes", "quantity": 1 }
]
},
"cod": 25000,
"notes": "Frágil"
}Comportamiento
- Las órdenes nuevas inician en el estado
Created(Creado). reference_idse deduplica por cliente: reenviar la misma referencia devuelve409 Conflict.- DovExpress genera el
codeinterno de la orden (ej.DOV_218345) — es el número de tracking que se muestra en el portal. Guardalo junto a tureference_id. - Hay campos opcionales de peso/dimensiones y cotización que la tabla de arriba no lista — ver §4.1.1.
Respuesta de éxito — 200 OK (no 201: el helper de respuesta usa 200 en todos los casos de éxito, incluida la creación):
{
"success": true,
"message": "Order Created",
"data": {
"code": "DOV_123456", // número de tracking de DovExpress
"referenceId": "REF00000000001", // la referencia que enviaste, o null
"id": "9f1c2e64-5b7a-4d3e-9a10-2c8f6b0d1a44"
// "pricing": { … } ← solo si tu cuenta tiene tarifa por matriz (§4.1.1)
}
}4.1.1 Cotización por matriz (opcional)
Si tu cuenta está en tarifa por matriz, la creación cotiza la guía y congela el precio. Si es una cuenta legacy, nada de esta subsección aplica: los campos siguen siendo opcionales y la clave pricing no aparece en la respuesta.
Campos adicionales de la solicitud — opcionales en el esquema, pero necesarios para poder cotizar:
| Campo | Tipo | Notas |
|---|---|---|
packageDetails.weightKg | number | Peso declarado, en kg. |
packageDetails.lengthCm / widthCm / heightCm | number | Dimensiones en cm; junto con el peso determinan la talla del paquete. |
payer | string | Quién paga el flete: SENDER (por defecto) o RECIPIENT. Con RECIPIENT el flete se suma al cod para formar el cobro total. |
districtId | string | Distrito del catálogo de DovExpress; alternativa a addressDetails.postalCode para resolver la zona. |
expected_pickup_date | string | Fecha esperada de recolección, YYYY-MM-DD en hora de Costa Rica. Ausente significa hoy. |
Objeto pricing en la respuesta — presente solo cuando la guía se cotizó; ausente para clientes legacy y en la respuesta de recuperación por referencia duplicada. Los montos son strings con dos decimales:
"pricing": {
"package_size": "M", // talla resuelta desde peso + dimensiones
"zone_group": "GAM", // grupo de zona del destino
"volume_ordinal": 128, // posición de la guía en tu volumen del período
"volume_tier": 2, // escalón de volumen que aplicó
"price_base": "2500.00", // flete antes de impuesto
"tax_rate": "0.13",
"tax_amount": "325.00",
"price_total": "2825.00", // price_base + tax_amount, exacto
"tax_included": true,
"currency": "CRC"
}Errores de cotización — ramificá sobre errorCode. La distinción 400 vs 422 es intencional: 400 es "corregí el payload y reintentá", 422 es "el payload está bien y DovExpress no puede cotizar".
| Status | errorCode | Qué pasó |
|---|---|---|
400 | MEASUREMENTS_REQUIRED | Falta el peso o alguna de las tres dimensiones. |
400 | INVALID_MEASUREMENTS | Peso o dimensión en cero o negativo. |
400 | DISTRICT_REQUIRED | No se pudo resolver el distrito (ni postalCode ni districtId utilizables). |
400 | INVALID_PAYER | payer fuera de SENDER / RECIPIENT. |
400 | INVALID_PICKUP_DATE | expected_pickup_date anterior a hoy. |
422 | PARCEL_NOT_QUOTABLE | Las medidas no caen en ninguna talla de la tarifa. |
422 | DISTRICT_NOT_FOUND | El distrito indicado no existe en el catálogo. |
422 | DISTRICT_NOT_ZONED | El distrito existe pero no tiene grupo de zona asignado. |
422 | ZONE_NOT_PRICED | La zona no tiene precio en la tarifa vigente. |
422 | TARIFF_NOT_FOUND | No hay versión de tarifa vigente para la fecha. |
422 | TARIFF_CELL_MISSING | Falta la celda talla × zona × escalón en la tarifa. |
422 | PRICING_MATRIX_NOT_ENABLED | La tarifa por matriz no está habilitada para tu cuenta. |
Un 422 no se arregla reintentando el mismo payload — es una condición de configuración de nuestro lado. Registralo, alertá aparte de los 400, y avisanos.
El detalle completo (esquemas CreateOrderRequest y Pricing, y los ejemplos de 400/422) está en la spec OpenAPI.
4.2 Obtener orden por id interno — GET /api/orders/:id
4.3 Obtener orden por referencia o código — GET /api/orders/reference/:reference
:reference coincide con tu reference_id o con el code de DovExpress. Tanto 4.2 como 4.3 están acotados a tu cliente y devuelven:
{
"success": true,
"message": "Order Found",
"data": {
"id": "uuid",
"code": "DOV_218345", // código de orden/tracking de DovExpress
"reference_id": "CR0256301601",
"contactDetails": { "contactName": "...", "contactPhone": "...", "contactPhone2": "..." },
"addressDetails": { "address": "...", "state": "...", "region": "...", "city": "...",
"country": "...", "postalCode": "...", "lat": 0, "lng": 0, "notes": "..." },
"packageDetails": { "product": "...", "quantity": "1" },
"cod": "25000", // Decimal — se serializa como string
"currentStatus": {
"code": 5015, "statusId": "uuid", "statusName": "Delivered", "statusNameEs": "Entregado",
"createdAt": "2026-02-11T17:38:58.000Z",
"pod": [ { "type": "photo", "url": "https://dovexpress.s3.amazonaws.com/uploads/photos/..." } ]
},
"pod": [ { "type": "photo", "url": "https://dovexpress.s3.amazonaws.com/uploads/photos/..." } ],
"trackingHistory": [
{ "code": 5014, "statusId": "uuid", "statusName": "Created", "statusNameEs": "Creado", "createdAt": "...", "pod": [] },
{ "code": 5028, "statusId": "uuid", "statusName": "In Transit", "statusNameEs": "En Tránsito", "createdAt": "...", "pod": [] },
{ "code": 5015, "statusId": "uuid", "statusName": "Delivered", "statusNameEs": "Entregado", "createdAt": "...",
"pod": [ { "type": "photo", "url": "https://dovexpress.s3.amazonaws.com/uploads/photos/..." } ] }
]
}
}Cada ítem de trackingHistory y el currentStatus incluyen el código DOV (code), los nombres en inglés (statusName) y español (statusNameEs), la marca de tiempo (createdAt), y un arreglo pod con las URLs de prueba de entrega de ese evento (poblado, p. ej., en Entregado). El objeto de la orden también lleva un pod de nivel superior (el POD del evento actual) y code (el código de orden/tracking de DovExpress). trackingHistory viene ordenado del más antiguo al más reciente. El §9 documenta los campos de POD en detalle, con el payload completo y todos los casos borde.
Una referencia inexistente — o que pertenece a otro cliente — devuelve 401 (no 404) con { "success": false, "message": "Orden no encontrada", … }.
4.4 Listar catálogo de estados — GET /api/statuses
Devuelve el catálogo compartido completo. Llamalo una vez al arrancar (y periódicamente) para mantener tu mapeo sincronizado, incluyendo cualquier estado que DovExpress agregue después:
curl https://api.dovexpresscr.com/api/statuses -H "api-key: $DOV_API_KEY"{
"success": true,
"message": "Status List",
"data": [
{ "code": 5014, "name": "Created", "name_es": "Creado", "is_final": false, "requires_photo": false, "requires_signature": false },
{ "code": 5015, "name": "Delivered", "name_es": "Entregado", "is_final": true, "requires_photo": true, "requires_signature": false },
{ "code": 5028, "name": "In Transit", "name_es": "En Tránsito", "is_final": false, "requires_photo": false, "requires_signature": false }
]
}code es el código DOV (ver §7). Mapeá tus estados internos contra él. Las filas vienen ordenadas por code ascendente y los estados retirados quedan excluidos. requires_photo / requires_signature te dicen qué estados producen prueba de entrega — ver §9.1.
4.5 Gestión self-service del webhook — GET / PUT / DELETE /api/webhook
Gestioná tu propio endpoint de webhook con tu api-key — sin intervención de DovExpress:
| Método | Efecto |
|---|---|
GET /api/webhook | Devuelve { "data": { "url": "...", "updatedAt": "..." } }, o { "data": null } si no hay. |
PUT /api/webhook | Cuerpo { "url": "https://tu-endpoint/hook" } → registra o actualiza (upsert; sobrescribe la URL anterior). |
DELETE /api/webhook | Elimina tu webhook. |
La relación es una URL de webhook por cliente (ClientsWebhooks, client_id único).
5. Webhooks
5.1 Configuración
El webhook es self-service vía tu api-key (§4.5): PUT para registrar/actualizar, GET para leer, DELETE para eliminar. Un admin de DovExpress también puede registrarlo por vos. Una URL de webhook por cliente; PUT hace upsert.
5.2 Entrega & payload
En cada cambio de estado, DovExpress envía un POST HTTP a tu URL con:
- Header
api-key— tu propia API key, exactamente la misma credencial con la que llamás a la API (§3). Es la que tenés que validar (§5.4). - Header
Authorization: Basic <token fijo>— deprecado, se sigue enviando por retrocompatibilidad con integraciones existentes. No construyas integraciones nuevas sobre él. - Cuerpo JSON con forma
{ "data": <payload> }.
A) Payload canónico de cambio de estado (lo que envían todos los flujos hoy):
{
"data": {
"id": "uuid",
"code": "DOV_218345",
"reference_id": "CR0256301601",
"contactDetails": { "...": "..." },
"addressDetails": { "...": "..." },
"packageDetails": { "product": "...", "quantity": "1" },
"cod": "25000",
"currentStatus": { "code": 5015, "statusId": "uuid", "statusName": "Delivered", "statusNameEs": "Entregado", "createdAt": "...",
"pod": [ { "type": "photo", "url": "https://dovexpress.s3.amazonaws.com/uploads/photos/..." } ] },
"pod": [ { "type": "photo", "url": "https://dovexpress.s3.amazonaws.com/uploads/photos/..." } ],
"trackingHistory": [ { "code": 5014, "statusId": "...", "statusName": "...", "statusNameEs": "...", "createdAt": "...", "pod": [] } ]
}
}Es el mismo objeto que devuelven los endpoints GET de orden (§4.2–4.3), documentado campo por campo en §9.3.
B) Forma mínima legacy (solo defensiva):
{ "data": { "orderId": "uuid", "statusId": "uuid", "userId": "uuid" } }ℹ️ Las operaciones masivas (asignación masiva, carga recibida) enviaban antes este cuerpo mínimo. Ya no: hoy todos los flujos reconstruyen el payload canónico A, así que
pod,code,reference_idy los nombres de estado llegan en todos. La forma de arriba solo es alcanzable si un llamador interno notifica sin id de orden, así que mantené una rama defensiva si ya la tenés (resolvela conGET /api/orders/:id, §4.2, mapeandostatusIdcontra tu caché del catálogo, §4.4) pero no diseñes alrededor de ella.
5.3 ¿Prueba de entrega en el webhook? — SÍ ✅
El trackingHistory del payload lleva un arreglo pod por evento, y el objeto de la orden expone un pod de nivel superior para el evento actual. En Entregado, este contiene la(s) URL(s) de la foto de prueba de entrega (y la firma cuando está presente), así que recibís el POD automáticamente — sin polling. El mismo enriquecimiento aplica a los endpoints GET de orden (§4.2–4.3).
→ El §9 es la referencia completa de POD: qué se captura, el flujo end-to-end, los ejemplos completos de webhook y GET, el formato de la URL del archivo, todos los casos borde y las limitaciones honestas. Leelo antes de implementar.
5.4 Manejo del webhook — recomendaciones
Verificá antes de confiar: cada notificación trae un header
api-keycuyo valor es tu propia API key — la misma que enviás ahttps://api.dovexpresscr.com. Comparala contra tu llave y rechazá cualquier otra cosa con401:jsif (req.headers['api-key'] !== MY_API_KEY) return res.sendStatus(401);Compará en tiempo constante si tu lenguaje lo hace fácil, y leé la llave de la configuración — nunca la hardcodees. El header legacy
Authorization: Basic <token fijo>se sigue enviando por retrocompatibilidad, pero está deprecado: no es por cliente, así que no prueba nada sobre quién te llamó. Validáapi-keyen su lugar.Protegé igual el endpoint: servilo solo por HTTPS y mantené la URL no adivinable (ej. un segmento de path aleatorio), para que la credencial no sea lo único entre internet y tu handler.
Confirmá rápido: devolvé HTTP
2xxapenas hayas persistido el evento; hacé el trabajo pesado de forma asíncrona para no bloquear a DovExpress en tu procesamiento.Sé idempotente: el mismo evento puede llegar más de una vez. Deduplicá con una clave estable — ej.
id+statusId+createdAt— y hacé que reprocesar sea un no-op.Manejá ambas formas: ramificá según el payload (rico con
currentStatusvs. mínimo conorderId/statusId/userId) y enriquecé la forma B vía la API como en §5.2.
6. Errores
La API usa códigos de estado HTTP convencionales. Los que conviene manejar explícitamente:
Los cuerpos de error son { "success": false, "message": "…", "data": …, "status": <status http>, "code": "…", "errorCode": "…" }; errorCode solo aparece en los flujos que definen uno.
data lleva el contexto del error, y en un 400 de validación es el dato más útil para vos: el arreglo completo de incidencias del esquema, cada una con su path, code y message — ahí ves exactamente qué campo del payload está mal. En los errores de negocio (precio, fecha de recolección) es null, en los genéricos [], y en unos pocos casos un objeto con el dato ofensor (p. ej. reference_id en un 409).
| Código | Cuándo | Qué hacer |
|---|---|---|
400 Bad Request | Falta el header api-key — message: "Required", con la incidencia en data (path: ["headers","api-key"]). | Enviá el header en todas las solicitudes. |
401 Unauthorized | api-key desconocida — message: "Client not found" (o, de tu lado, un webhook cuyo header api-key no coincide con tu llave). | Revisá la credencial/header. |
401 Unauthorized | El id/referencia de la orden no existe, o no es tuya — mensaje Orden no encontrada. | Verificá el identificador; las órdenes están acotadas al cliente. La API no distingue a propósito entre "no existe" y "es de otro", así que esto no es un 404 ni un motivo para rotar tu clave. |
400 Bad Request | Cuerpo inválido — falta un campo requerido, o address fuera de 1..2000 caracteres. También la familia 400 de cotización (§4.1.1). | Corregí el payload antes de reintentar. |
422 Unprocessable Entity | El payload es válido y DovExpress no puede cotizar la guía (solo clientes con tarifa por matriz) — ver §4.1.1. | No reintentes igual; es una condición operativa, alertá aparte. |
409 Conflict | reference_id ya usada por tu cliente. | Tratalo como "ya creada"; no reintentes a ciegas. |
429 Too Many Requests | Más de 600 solicitudes/minuto por api-key (o 3 000/minuto por IP de origen). errorCode: "RATE_LIMITED". | Respetá el header Retry-After; bajá el ritmo de tu polling (§9.4). |
5xx | Error transitorio del servidor. | Reintentá con backoff; las entregas de webhook que no logres confirmar con 2xx reconciliálas con polling. |
Tratá los
4xxcomo error tuyo (corregí y reintentá deliberadamente) y los5xx/errores de red como transitorios (reintentá con backoff exponencial). Para seguridad idempotente en creaciones, usá tureference_idcomo clave.
7. Catálogo de estados
Todos los clientes comparten un único catálogo Status. GET /api/statuses devuelve actualmente 45 estados.
No hardcodees códigos. Cada estado tiene un código DOV — un entero propio de DovExpress, autoasignado y estable (
MAX+1, empezando en5001; los códigos existentes nunca cambian). Obtené el catálogo en vivo desdeGET /api/statuses(§4.4) y mapeá contracode. La tabla de abajo es una foto para humanos; elcode/name/name_esautoritativo siempre viene de la API.
7.1 Foto del catálogo
Los nombres se reproducen exactamente como los devuelve la API, incluyendo la capitalización inconsistente y la grafía Adress — hacé el match por code, no por el string.
| code | name | name_es | Final | Foto |
|---|---|---|---|---|
5001 | 2nd Attempt Consignee Not Available | 2do Intento Destinatario No Disponible | ||
5002 | 2nd attempt Incorrect Adress | 2do Intento Dirección Incorrecta | ||
5003 | 2nd Attempt Refused by Consignee | 2do Intento Rehusado por Destinatario | ||
5004 | 2nd Attempt Unknown Consignee | 2do Intento Destinatario Desconocido | ||
5005 | Absent | Ausente | ||
5006 | Accepted | Aceptado | ||
5007 | Already Received the Package | Ya Recibió el Paquete | ||
5008 | Assigned to Distributor | Asignado a Distribuidor | ||
5009 | Assigned to Driver | Asignado a Conductor | ||
5010 | Bought Other Product | Compró Otro Producto | ||
5011 | Call Center Time | Tiempo Call Center | ||
5012 | Cannot Locate | No se Localiza | ||
5013 | Cargo Received | Carga Recibida | ||
5014 | Created | Creado | ||
5015 | Delivered | Entregado | ✅ | 📷 |
5016 | Did Not Request Anything | No Ha Solicitado Nada | ||
5017 | Does Not Want It | No lo Quiere | ||
5018 | Duplicate Package | Paquete Repetido | 📷 | |
5019 | First Attempt | Primer Intento | ||
5020 | First attempt Consignee Moved | 1er Intento Cambió Domicilio | ||
5021 | First attempt Consignee Not Available | 1er Intento Destinatario No Disponible | ||
5022 | First attempt Inaccessible Delivery Zone | 1er Intento Fuera de Cobertura | ||
5023 | First attempt Incorrect Adress | 1er Intento Dirección Errónea | ||
5024 | First attempt Refused by Consignee | 1er Intento Rehusado por Destinatario | ||
5025 | First attempt Unknown Consignee | 1er Intento Destinatario Desconocido | ||
5026 | Hospitalized | Hospitalizado | ||
5027 | Incorrect Phone | Teléfono Incorrecto | ||
5028 | In Transit | En Tránsito | ||
5029 | No Money | Sin Dinero | ||
5030 | Other Promotion | Otra Promoción | ||
5031 | Out of Coverage | Fuera de Cobertura | ||
5032 | Out of the country | Fuera del País | ||
5033 | Package in Warehouse | Paquete en Bodega | ||
5035 | Received by Distributor | Recibido por Distribuidor | ||
5036 | Rejected | Rechazado | ||
5037 | Rescheduled | Reprogramado | 📷 | |
5038 | Returned to Sender | Devuelto a Remitente | ✅ | |
5040 | Returned to Warehouse (DOV) | Devuelto a Bodega (DOV) | ||
5041 | Second Attempt | Segundo Intento | ||
5042 | To Rescue | Para Rescatar | ||
5043 | Will Buy Later | Comprará Más Adelante | ||
5044 | Wrong Address | Dirección Errónea | ||
5045 | Wrong Price | Precio Incorrecto | ||
5046 | Wrong Product | Producto Incorrecto | ||
5047 | At Pickup Point | En Punto de Recogida |
5034 y 5039 están retirados y quedan fuera de GET /api/statuses; los huecos en la numeración son normales y los códigos nunca se reutilizan.
7.2 Flags que importan
Estados finales: solo Delivered / Entregado (
5015) y Returned to Sender / Devuelto a Remitente (5038). Una vez que una orden es final, su estado ya no puede cambiar — dejá de consultarla. Foto requerida al asignar (requires_photo: true): Delivered (5015), Duplicate Package (5018), Rescheduled (5037). Son los únicos estados que pueden llevar POD — ver §9.1. Firma requerida (requires_signature: true): ninguno. Hoy ningún estado del catálogo exige firma (§9.1). Un ciclo de vida típico se ve así:Created (5014) → Cargo Received (5013) → Assigned to Driver (5009) → In Transit (5028) → Accepted (5006) → Delivered (5015) + foto POD.
8. Zona horaria
El proceso de la API corre con TZ='America/Costa_Rica' (UTC−6, sin horario de verano). Las marcas de tiempo de los eventos de tracking se gestionan y muestran en hora de Costa Rica. Las marcas serializadas en JSON son ISO-8601; convertí a America/Costa_Rica para mostrarlas en local.
9. Prueba de entrega (POD)
Esta sección es autocontenida: un equipo de integración debería poder implementar POD de punta a punta solo con esto. Cada nombre de campo, ruta, código de estado y comportamiento de acá abajo fue verificado contra api/src (services/webhooks.ts, controllers/api.ts, controllers/drivers.ts, services/s3.ts) y contra el catálogo de estados en vivo.
9.1 Qué es un POD en DovExpress
Un POD es uno o más archivos de imagen adjuntos a un único evento de tracking. Cada archivo tiene un type:
type | Qué es | Se captura cuando |
|---|---|---|
photo | Foto tomada con la cámara del conductor | El estado que se asigna tiene requires_photo: true |
signature | Firma trazada en la pantalla del conductor | El estado que se asigna tiene requires_signature: true |
Esos dos flags pertenecen al catálogo de estados y los devuelve GET /api/statuses (§4.4). En el catálogo en vivo, los estados que producen POD son:
| código DOV | name | name_es | requires_photo | requires_signature |
|---|---|---|---|---|
5015 | Delivered | Entregado | ✅ | – |
5018 | Duplicate Package | Paquete Repetido | ✅ | – |
5037 | Rescheduled | Reprogramado | ✅ | – |
Todos los demás estados tienen ambos flags en false y nunca producen POD. En una entrega normal recibís entonces exactamente una photo, en Delivered (5015).
Sobre las firmas — la respuesta honesta: hoy requires_signature es false para todos los estados del catálogo, así que en producción no se está capturando ninguna firma. No armes un flujo que espere una. Aun así el camino de captura y publicación está completo (app del conductor → S3 → OrderFiles.file_type = 'signature' → pod), así que el día que DovExpress active el flag en un estado, pod simplemente empieza a traer una segunda entrada con "type": "signature" y nada más del contrato cambia. Es decir: recorré el arreglo y filtrá por type — nunca asumas que tiene exactamente un elemento.
Sobre lo "obligatorio" — también honesto: el requisito lo hace cumplir la app del conductor, que no permite enviar un estado con requires_photo: true hasta que se capture una foto. El endpoint al que la app hace POST acepta el cambio de estado con o sin imagen. La consecuencia para vos está en §9.6: un evento Delivered generado por un operador desde el panel admin (cambio individual u operación masiva de bodega) no lleva POD, y su pod queda en [].
9.2 Flujo end-to-end
App del conductor — el conductor asigna "Entregado" (5015)
│ foto (base64) + firma (base64, solo si el estado la pide)
▼
Endpoint interno de conductores (no es parte de tu superficie de integración)
│
├─ 1. sube cada imagen a S3 ──▶ uploads/photos/<uuid>-<epoch_ms>
│ uploads/signatures/<uuid>-<epoch_ms>
├─ 2. escribe el cambio ──▶ evento de Tracking (estado + timestamp)
├─ 3. liga los archivos ──▶ OrderFiles { tracking_id, file_type, file_url }
└─ 4. recién entonces: te notifica
│
▼
Tu webhook ◀── POST { "data": { …, "pod": [ { "type": "photo", "url": … } ], … } }
(opción A, §9.3)
Tu poller ──▶ GET /api/orders/reference/:reference → el mismo objeto
(opción B, §9.4)Que el paso 4 corra al final es lo que garantiza que el POD ya viene en el payload: el arreglo pod se lee de las filas de OrderFiles escritas en el paso 3.
9.3 Opción A — webhook (push; recomendado)
Registrá tu endpoint una vez (§4.5). Desde ahí, cada cambio de estado llega como un POST HTTP con el header api-key puesto en tu propia API key — validalo como en §5.4 — y cuerpo { "data": <payload> }. El header legacy Authorization: Basic sigue viajando junto, deprecado.
Este es el payload completo de Delivered, exactamente como lo construye la API. Todos los valores del ejemplo son ilustrativos — código, referencia, ids, contacto, dirección y URL de POD son ficticios; lo que no es ilustrativo es la forma:
{
"data": {
"id": "9f1c2e64-5b7a-4d3e-9a10-2c8f6b0d1a44",
"code": "DOV_123456",
"reference_id": "REF00000000001",
"contactDetails": {
"contactName": "Juan Perez",
"contactPhone": "88888888",
"contactPhone2": null
},
"addressDetails": {
"address": "200m norte de la iglesia",
"state": "San José",
"region": "Central",
"city": "Escazú",
"country": "Costa Rica",
"postalCode": "10203",
"lat": 9.9281,
"lng": -84.0907,
"notes": "Llamar antes"
},
"packageDetails": { "product": "Shoes - 1", "quantity": "1" },
"cod": "25000",
"currentStatus": {
"code": 5015,
"statusId": "1f6b9c07-3d2a-4e51-8b6c-0a9d7e4f2b13",
"statusName": "Delivered",
"statusNameEs": "Entregado",
"createdAt": "2026-06-18T18:18:14.407Z",
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
]
},
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
],
"trackingHistory": [
{ "code": 5014, "statusId": "…", "statusName": "Created", "statusNameEs": "Creado", "createdAt": "2026-06-12T00:02:55.000Z", "pod": [] },
{ "code": 5013, "statusId": "…", "statusName": "Cargo Received", "statusNameEs": "Carga Recibida", "createdAt": "2026-06-15T22:16:20.000Z", "pod": [] },
{ "code": 5009, "statusId": "…", "statusName": "Assigned to Driver", "statusNameEs": "Asignado a Conductor","createdAt": "2026-06-15T23:32:31.000Z", "pod": [] },
{ "code": 5028, "statusId": "…", "statusName": "In Transit", "statusNameEs": "En Tránsito", "createdAt": "2026-06-15T23:32:31.000Z", "pod": [] },
{
"code": 5015, "statusId": "…", "statusName": "Delivered", "statusNameEs": "Entregado",
"createdAt": "2026-06-18T18:18:14.407Z",
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
]
}
]
}
}Campo por campo:
| Ruta | Tipo | Notas |
|---|---|---|
data.id | string uuid | Id interno de la orden — es el :id de GET /api/orders/:id (§4.2). |
data.code | string | Código de tracking de DovExpress, DOV_######. |
data.reference_id | string | null | La referencia que enviaste al crear; null si no enviaste ninguna. |
data.packageDetails.quantity | string | Se guarda como texto, así que llega entre comillas ("1"). |
data.cod | string | null | Decimal, serializado como string ("25000"); los ceros finales se descartan. Parsealo, no asumas número JSON. |
data.currentStatus | objeto | null | El evento de tracking más reciente. null solo para una orden sin ningún evento. |
data.currentStatus.code | number | null | Código DOV — 5015 es Delivered. null solo si un estado no tiene código DOV asignado. |
data.currentStatus.statusId | string uuid | Id interno de la fila Status. No lo uses como clave de mapeo — mapeá por code (el código DOV, dov_status_code). Sirve solo para correlacionar con la forma legacy del webhook (§5.2 B). |
data.currentStatus.statusName / statusNameEs | string | Nombres EN / ES, idénticos a los de GET /api/statuses. |
data.currentStatus.createdAt | string ISO-8601 | Esta es la marca de tiempo del POD — cuándo el conductor envió el estado. En JSON viene en UTC; ver §8 para la zona horaria. |
data.currentStatus.pod | arreglo | POD de ese evento. [] cuando el evento no tiene. Nunca null, nunca ausente. |
data.currentStatus.pod[].type | "photo" | "signature" | Solo se emiten estos dos valores. |
data.currentStatus.pod[].url | string | URL absoluta del archivo — ver §9.5. |
data.pod | arreglo | Espejo de conveniencia de currentStatus.pod (literalmente currentStatus?.pod ?? []). No es la unión de todos los eventos. |
data.trackingHistory | arreglo | Todos los eventos de la orden, del más antiguo al más reciente, cada uno con su code, nombres, createdAt y pod. |
De dónde leer el POD, en orden de preferencia:
data.currentStatus.pod— el POD del evento que disparó esta entrega. Es lo que querés en un handler de webhook.data.pod— mismo contenido, un nivel arriba; útil si solo persistís un registro plano.data.trackingHistory[n].pod— POD por evento en todo el historial; usalo para backfill o para ubicar el POD de un evento puntual (p. ej. el que tienecode5015).
El orden de las entradas dentro de un arreglo pod no está garantizado. Seleccioná por type, no por índice.
9.4 Opción B — polling (GET)
El mismo objeto está disponible bajo demanda. :reference acepta tu reference_id o el code de DovExpress:
curl -sS https://api.dovexpresscr.com/api/orders/reference/REF00000000001 \
-H "api-key: $DOV_API_KEY"200 OK — el envoltorio de respuesta lleva el mismo objeto data que envía el webhook:
{
"success": true,
"message": "Order Found",
"data": {
"id": "9f1c2e64-5b7a-4d3e-9a10-2c8f6b0d1a44",
"code": "DOV_123456",
"reference_id": "REF00000000001",
"contactDetails": {
"contactName": "Juan Perez",
"contactPhone": "88888888",
"contactPhone2": null
},
"addressDetails": {
"address": "200m norte de la iglesia",
"state": "San José",
"region": "Central",
"city": "Escazú",
"country": "Costa Rica",
"postalCode": "10203",
"lat": 9.9281,
"lng": -84.0907,
"notes": "Llamar antes"
},
"packageDetails": { "product": "Shoes - 1", "quantity": "1" },
"cod": "25000",
"currentStatus": {
"code": 5015,
"statusId": "1f6b9c07-3d2a-4e51-8b6c-0a9d7e4f2b13",
"statusName": "Delivered",
"statusNameEs": "Entregado",
"createdAt": "2026-06-18T18:18:14.407Z",
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
]
},
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
],
"trackingHistory": [
{ "code": 5014, "statusId": "…", "statusName": "Created", "statusNameEs": "Creado", "createdAt": "2026-06-12T00:02:55.000Z", "pod": [] },
{ "code": 5013, "statusId": "…", "statusName": "Cargo Received", "statusNameEs": "Carga Recibida", "createdAt": "2026-06-15T22:16:20.000Z", "pod": [] },
{ "code": 5009, "statusId": "…", "statusName": "Assigned to Driver", "statusNameEs": "Asignado a Conductor","createdAt": "2026-06-15T23:32:31.000Z", "pod": [] },
{ "code": 5028, "statusId": "…", "statusName": "In Transit", "statusNameEs": "En Tránsito", "createdAt": "2026-06-15T23:32:31.000Z", "pod": [] },
{
"code": 5015, "statusId": "…", "statusName": "Delivered", "statusNameEs": "Entregado",
"createdAt": "2026-06-18T18:18:14.407Z",
"pod": [
{
"type": "photo",
"url": "https://dovexpress.s3.amazonaws.com/uploads/photos/00000000-0000-0000-0000-000000000000-1700000000000"
}
]
}
]
}
}GET /api/orders/:id (§4.2) devuelve exactamente el mismo cuerpo para el id interno.
Referencia inexistente: la API responde
401, no404, con{ "success": false, "message": "Orden no encontrada", … }. El mismo401sale para una referencia que existe pero es de otro cliente — los dos casos son indistinguibles a propósito. No lo trates como falla de autenticación ni rotes tu clave por eso.
¿Webhook o polling?
- El webhook es la opción por defecto. El POD te llega segundos después de que el conductor envía el estado, con una llamada entrante por evento y sin ningún schedule que operar.
- Hacé polling cuando no tengas un endpoint HTTPS público; cuando necesites backfill de órdenes que cambiaron antes de registrar el webhook; cuando no lograste responder
2xxy querés reconciliar; o para una auditoría periódica de que tu archivo de PODs coincide con el nuestro. - Presupuesto: la API de integración permite 600 solicitudes por minuto por
api-key(más un techo de 3 000/minuto por IP de origen). Al pasarte recibís429con headerRetry-AfteryerrorCode: "RATE_LIMITED". RespetáRetry-After. - No consultes órdenes terminadas.
DeliveredyReturned to Senderson finales y ya no pueden cambiar (§7.2) — una vez que guardaste el POD, dejá de consultar esa orden. Un loop de reconciliación que solo recorra órdenes no finales cada varios minutos queda muy por debajo del límite.
9.5 La URL del archivo de POD
Formato —
https://<bucket>.s3.amazonaws.com/<carpeta>/<uuid>-<epoch_ms>. En producción el bucket esdovexpress:- fotos →
https://dovexpress.s3.amazonaws.com/uploads/photos/<uuid>-<epoch_ms> - firmas →
https://dovexpress.s3.amazonaws.com/uploads/signatures/<uuid>-<epoch_ms>
<uuid>es aleatorio por archivo y<epoch_ms>es la hora de subida en milisegundos. La llave no lleva extensión de archivo, a propósito.- fotos →
No está pre-firmada y no expira. La URL no lleva query string, ni firma, ni credenciales; es una URL de objeto plana que consultás con
GETtal cual, y no rota. La API nunca firma URLs de POD.Lectura. Los objetos se suben sin ACL por objeto, así que el acceso público de lectura viene de la política del bucket; las URLs de POD que DovExpress entrega a los socios se descargan sin credenciales. Si alguna URL de POD llegara a responder
403, reportalo en vez de reintentar en loop.Content type. El objeto conserva el MIME que envió la app del conductor —
image/jpegen la práctica,image/pngpara firmas, con fallback aimage/jpegsi la app no declara nada. Como la llave no tiene extensión, tomá el formato del headerContent-Typede la respuesta, no de la URL.Límites de tamaño en la subida: menos de 1 KB se rechaza como corrupto, más de 10 MB se rechaza. Una foto de entrega real pesa unos cientos de KB.
Recomendación: replicá el archivo. Cuando procesés el evento, descargá la imagen y guardá tu propia copia junto a tu registro del envío. Una sola solicitud vuelve tu archivo de PODs independiente de cualquier cambio futuro de política o de ciclo de vida del bucket de nuestro lado, y del caso de borrado lógico de §9.6.
9.6 Casos borde, verificados
- Antes de la entrega,
podes[]. Todo evento sin archivos lleva"pod": [], y tantodata.podcomodata.currentStatus.podson[]hasta que exista un evento con archivos. La llave siempre está presente y siempre es un arreglo — nuncanull, nunca omitida. - Los estados que no piden foto nunca producen POD.
Created,Cargo Received,Assigned to Driver,In Transit,Accepted, todos los estados de intento/excepción — todos conpod: []. Solo los tres estados de §9.1 pueden llevar uno. data.podsigue al evento más reciente, no a "la entrega". Es exactamentecurrentStatus.pod. ComoDeliveredes final, después no se puede agregar nada, así que una vez entregada ambos coinciden. Si querés "el POD de la entrega" sin depender de la posición, tomá el ítem detrackingHistorycuyocodesea5015.- Un evento
Deliveredpuede tenerpodvacío legítimamente. Pasa cuando el estado se asignó desde el panel admin o por una operación masiva en lugar de la app del conductor (§9.1). Registralo y reconciliá; no cortes tu pipeline por un POD ausente. - Las fotos que no están adjuntas a un evento de tracking no se exponen. El personal de DovExpress también puede adjuntar una foto a una orden desde el panel; esas filas no tienen evento de tracking, y
podse construye estrictamente con los archivos de un evento de tracking, así que esas fotos nunca aparecen en la API ni en el webhook. - Los archivos borrados desaparecen de las lecturas posteriores. Los archivos de POD se filtran por "no borrado lógicamente". Si un archivo se elimina de nuestro lado, su entrada deja de aparecer en las respuestas siguientes — una razón más para replicar la imagen (§9.5).
- El mismo evento puede llegar dos veces. Deduplicá por
id+statusId+createdAt(§5.4); el POD de un evento dado es idéntico entre reentregas. - Todos los flujos de webhook llevan POD hoy. Incluidas las operaciones masivas que antes enviaban el cuerpo mínimo
{ orderId, statusId, userId }: hoy pasan por el mismo constructor, así quepodviene en todas las entregas (ver la nota en §5.2).
9.7 Qué no incluye el POD
Dicho explícitamente para que nadie tenga que preguntar:
- No hay identidad de quien recibe. No existe un campo con quién recibió el paquete — ni nombre, ni número de identificación, ni relación con el destinatario, ni texto de "recibido por". Una entrada de
podtiene exactamente dos campos:typeyurl. - No hay geolocalización, datos del dispositivo, OTP ni confirmación por PIN. Nada de eso se captura como parte del POD.
- No hay firma hoy —
requires_signatureesfalseen todo el catálogo (§9.1). - No hay metadatos de la imagen en el payload — ni nombre de archivo, ni tamaño, ni MIME, ni checksum. El MIME lo averiguás por la respuesta HTTP cuando descargás la URL.
- Una sola marca de tiempo: el
createdAtdel evento, que es cuándo el conductor envió el estado. Tratalo como la hora de entrega (§8 para la zona horaria). - No hay endpoint dedicado de POD. No existe
GET /api/orders/:id/pod. El POD solo se expone embebido en el payload de la orden — ver §9.8.
9.8 Referencia rápida de POD
| Endpoint / canal | Dónde aparece pod | Contenido |
|---|---|---|
Webhook POST a tu URL (§5.2) | data.currentStatus.pod · data.pod · data.trackingHistory[].pod | POD del evento que disparó, más el POD por evento de todo el historial |
GET /api/orders/:id (§4.2) | data.currentStatus.pod · data.pod · data.trackingHistory[].pod | Los mismos tres lugares, bajo demanda |
GET /api/orders/reference/:reference (§4.3) | data.currentStatus.pod · data.pod · data.trackingHistory[].pod | Igual, buscando por tu referencia o el código DOV |
GET /api/statuses (§4.4) | sin pod — devuelve requires_photo / requires_signature | Te dice qué estados pueden producir POD |
POST /api/orders/create (§4.1) | sin pod | Una orden nueva todavía no tiene evento de tracking con archivos |
10. Ciclo de vida de estados & roadmap
Cuando un admin de DovExpress agrega un nuevo estado, se le asigna automáticamente el siguiente código DOV libre (MAX+1, empezando en 5001) — único y estable, así los códigos existentes nunca se mueven. El nuevo estado aparece de inmediato en GET /api/statuses. Te mantenés sincronizado consultando ese endpoint; como los códigos son estables, un simple diff contra tu catálogo guardado revela las novedades.
Entregado en esta integración:
- ✅ Endpoint de catálogo de estados —
GET /api/statuses(§4.4). - ✅ Payload de webhook enriquecido y consistente en todos los flujos (individual + masivo), incluyendo
code,reference_id,statusName,statusNameEs,createdAt. - ✅ POD en el webhook en
Delivered— URL(s) de archivo adjuntas automáticamente (§5.3). - ✅ Gestión self-service del webhook —
GET/PUT/DELETE /api/webhookcon upsert (§4.5).
Opcional / futuro [PROPUESTO]:
- Evento push
status.created— notificar proactivamente a los clientes integrados cuando se agrega un nuevo estado (hoy lo detectás consultandoGET /api/statuses).
11. Referencia rápida
| Capacidad | Estado |
|---|---|
Crear orden (POST /api/orders/create) | ✅ Implementado |
| Obtener orden por id / referencia | ✅ Implementado |
| Marcas de tiempo de Costa Rica (UTC−6) | ✅ Implementado |
| Webhook en cambio de estado (por cliente) | ✅ Implementado |
| Payload rico y consistente (individual + masivo) | ✅ Implementado |
| Foto POD en webhook / API (§9) | ✅ Implementado |
Endpoint de catálogo de estados (GET /api/statuses) | ✅ Implementado |
Gestión self-service del webhook (GET/PUT/DELETE /api/webhook) | ✅ Implementado |
| Códigos DOV (auto, únicos, estables) | ✅ Implementado |
| Captura de firma en el POD | ⚪ Construido, pero hoy ningún estado la exige (§9.1) |
| Identidad de quien recibe en el POD (nombre / cédula) | ❌ No disponible (§9.7) |
Notificación push de nuevo estado (status.created) | ❌ Opcional / futuro |
Chuleta de endpoints — todos requieren el header api-key, base https://api.dovexpresscr.com:
| Método | Ruta | Propósito |
|---|---|---|
POST | /api/orders/create | Crear una orden |
GET | /api/orders/:id | Obtener orden por id interno |
GET | /api/orders/reference/:reference | Obtener orden por tu referencia o código DOV |
GET | /api/statuses | Listar el catálogo de estados |
GET | /api/webhook | Leer tu URL de webhook |
PUT | /api/webhook | Registrar / actualizar tu URL de webhook |
DELETE | /api/webhook | Eliminar tu webhook |