Skip to content

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 (controladores api.ts, orders.ts, clients.ts, status.ts, drivers.ts; servicios webhooks.ts, s3.ts) y el catálogo Status en 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:

La spec y esta guía se publican desde la misma fuente, así que no se desincronizan.

Contenido

  1. Resumen
  2. Inicio rápido
  3. Autenticación
  4. Endpoints
  5. Webhooks
  6. Errores
  7. Catálogo de estados
  8. Zona horaria
  9. Prueba de entrega (POD)
  10. Ciclo de vida de estados & roadmap
  11. 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 POST por 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:

  1. Obtené tu api-key de DovExpress (tu única credencial — mantenela secreta).
  2. 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" }'
  3. 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" }
      }'
  4. 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.com

  • Credencial: 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 ausente400 Bad Request con message: "Required" — lo produce el validador de esquema, y data trae la incidencia con path: ["headers","api-key"].
  • Llave desconocida (no corresponde a ningún cliente) → 401 Unauthorized con message: "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

CampoTipoRequeridoNotas
reference_idstringTu referencia de tracking. Única por cliente (reuso → 409).
contactDetails.contactNamestringNombre del destinatario.
contactDetails.contactPhonestringTeléfono principal.
contactDetails.contactPhone2stringTeléfono secundario.
addressDetails.addressstring1..2000 caracteres.
addressDetails.postalCodestring
addressDetails.state / region / city / countrystring
addressDetails.lat / lngnumberCoordenadas para ruteo.
addressDetails.notesstringNotas de entrega para el conductor.
packageDetails.productstring"Nombre - cant", o separado por comas "A - 1, B - 2".
packageDetails.quantitynumber
packageDetails.products[]arrayAlternativa estructurada: { product, name, quantity }.
codnumberMonto contra entrega (cash-on-delivery).
notesstringNotas a nivel de orden.

Cuerpo de ejemplo

jsonc
{
  "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_id se deduplica por cliente: reenviar la misma referencia devuelve 409 Conflict.
  • DovExpress genera el code interno de la orden (ej. DOV_218345) — es el número de tracking que se muestra en el portal. Guardalo junto a tu reference_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):

jsonc
{
  "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:

CampoTipoNotas
packageDetails.weightKgnumberPeso declarado, en kg.
packageDetails.lengthCm / widthCm / heightCmnumberDimensiones en cm; junto con el peso determinan la talla del paquete.
payerstringQuién paga el flete: SENDER (por defecto) o RECIPIENT. Con RECIPIENT el flete se suma al cod para formar el cobro total.
districtIdstringDistrito del catálogo de DovExpress; alternativa a addressDetails.postalCode para resolver la zona.
expected_pickup_datestringFecha 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:

jsonc
"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".

StatuserrorCodeQué pasó
400MEASUREMENTS_REQUIREDFalta el peso o alguna de las tres dimensiones.
400INVALID_MEASUREMENTSPeso o dimensión en cero o negativo.
400DISTRICT_REQUIREDNo se pudo resolver el distrito (ni postalCode ni districtId utilizables).
400INVALID_PAYERpayer fuera de SENDER / RECIPIENT.
400INVALID_PICKUP_DATEexpected_pickup_date anterior a hoy.
422PARCEL_NOT_QUOTABLELas medidas no caen en ninguna talla de la tarifa.
422DISTRICT_NOT_FOUNDEl distrito indicado no existe en el catálogo.
422DISTRICT_NOT_ZONEDEl distrito existe pero no tiene grupo de zona asignado.
422ZONE_NOT_PRICEDLa zona no tiene precio en la tarifa vigente.
422TARIFF_NOT_FOUNDNo hay versión de tarifa vigente para la fecha.
422TARIFF_CELL_MISSINGFalta la celda talla × zona × escalón en la tarifa.
422PRICING_MATRIX_NOT_ENABLEDLa 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:

jsonc
{
  "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:

bash
curl https://api.dovexpresscr.com/api/statuses -H "api-key: $DOV_API_KEY"
jsonc
{
  "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étodoEfecto
GET /api/webhookDevuelve { "data": { "url": "...", "updatedAt": "..." } }, o { "data": null } si no hay.
PUT /api/webhookCuerpo { "url": "https://tu-endpoint/hook" } → registra o actualiza (upsert; sobrescribe la URL anterior).
DELETE /api/webhookElimina 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-keytu 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):

jsonc
{
  "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):

jsonc
{ "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_id y 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 con GET /api/orders/:id, §4.2, mapeando statusId contra tu caché del catálogo, §4.4) pero no diseñes alrededor de ella.

5.3 ¿Prueba de entrega en el webhook? —

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-key cuyo valor es tu propia API key — la misma que enviás a https://api.dovexpresscr.com. Comparala contra tu llave y rechazá cualquier otra cosa con 401:

    js
    if (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-key en 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 2xx apenas 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 currentStatus vs. mínimo con orderId/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ódigoCuándoQué hacer
400 Bad RequestFalta el header api-keymessage: "Required", con la incidencia en data (path: ["headers","api-key"]).Enviá el header en todas las solicitudes.
401 Unauthorizedapi-key desconocidamessage: "Client not found" (o, de tu lado, un webhook cuyo header api-key no coincide con tu llave).Revisá la credencial/header.
401 UnauthorizedEl 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 RequestCuerpo 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 EntityEl 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 Conflictreference_id ya usada por tu cliente.Tratalo como "ya creada"; no reintentes a ciegas.
429 Too Many RequestsMá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).
5xxError transitorio del servidor.Reintentá con backoff; las entregas de webhook que no logres confirmar con 2xx reconciliálas con polling.

Tratá los 4xx como error tuyo (corregí y reintentá deliberadamente) y los 5xx/errores de red como transitorios (reintentá con backoff exponencial). Para seguridad idempotente en creaciones, usá tu reference_id como 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 en 5001; los códigos existentes nunca cambian). Obtené el catálogo en vivo desde GET /api/statuses (§4.4) y mapeá contra code. La tabla de abajo es una foto para humanos; el code/name/name_es autoritativo siempre viene de la API.

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.

codenamename_esFinalFoto
50012nd Attempt Consignee Not Available2do Intento Destinatario No Disponible
50022nd attempt Incorrect Adress2do Intento Dirección Incorrecta
50032nd Attempt Refused by Consignee2do Intento Rehusado por Destinatario
50042nd Attempt Unknown Consignee2do Intento Destinatario Desconocido
5005AbsentAusente
5006AcceptedAceptado
5007Already Received the PackageYa Recibió el Paquete
5008Assigned to DistributorAsignado a Distribuidor
5009Assigned to DriverAsignado a Conductor
5010Bought Other ProductCompró Otro Producto
5011Call Center TimeTiempo Call Center
5012Cannot LocateNo se Localiza
5013Cargo ReceivedCarga Recibida
5014CreatedCreado
5015DeliveredEntregado📷
5016Did Not Request AnythingNo Ha Solicitado Nada
5017Does Not Want ItNo lo Quiere
5018Duplicate PackagePaquete Repetido📷
5019First AttemptPrimer Intento
5020First attempt Consignee Moved1er Intento Cambió Domicilio
5021First attempt Consignee Not Available1er Intento Destinatario No Disponible
5022First attempt Inaccessible Delivery Zone1er Intento Fuera de Cobertura
5023First attempt Incorrect Adress1er Intento Dirección Errónea
5024First attempt Refused by Consignee1er Intento Rehusado por Destinatario
5025First attempt Unknown Consignee1er Intento Destinatario Desconocido
5026HospitalizedHospitalizado
5027Incorrect PhoneTeléfono Incorrecto
5028In TransitEn Tránsito
5029No MoneySin Dinero
5030Other PromotionOtra Promoción
5031Out of CoverageFuera de Cobertura
5032Out of the countryFuera del País
5033Package in WarehousePaquete en Bodega
5035Received by DistributorRecibido por Distribuidor
5036RejectedRechazado
5037RescheduledReprogramado📷
5038Returned to SenderDevuelto a Remitente
5040Returned to Warehouse (DOV)Devuelto a Bodega (DOV)
5041Second AttemptSegundo Intento
5042To RescuePara Rescatar
5043Will Buy LaterComprará Más Adelante
5044Wrong AddressDirección Errónea
5045Wrong PricePrecio Incorrecto
5046Wrong ProductProducto Incorrecto
5047At Pickup PointEn 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:

typeQué esSe captura cuando
photoFoto tomada con la cámara del conductorEl estado que se asigna tiene requires_photo: true
signatureFirma trazada en la pantalla del conductorEl 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 DOVnamename_esrequires_photorequires_signature
5015DeliveredEntregado
5018Duplicate PackagePaquete Repetido
5037RescheduledReprogramado

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:

jsonc
{
  "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:

RutaTipoNotas
data.idstring uuidId interno de la orden — es el :id de GET /api/orders/:id (§4.2).
data.codestringCódigo de tracking de DovExpress, DOV_######.
data.reference_idstring | nullLa referencia que enviaste al crear; null si no enviaste ninguna.
data.packageDetails.quantitystringSe guarda como texto, así que llega entre comillas ("1").
data.codstring | nullDecimal, serializado como string ("25000"); los ceros finales se descartan. Parsealo, no asumas número JSON.
data.currentStatusobjeto | nullEl evento de tracking más reciente. null solo para una orden sin ningún evento.
data.currentStatus.codenumber | nullCódigo DOV — 5015 es Delivered. null solo si un estado no tiene código DOV asignado.
data.currentStatus.statusIdstring uuidId 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 / statusNameEsstringNombres EN / ES, idénticos a los de GET /api/statuses.
data.currentStatus.createdAtstring ISO-8601Esta 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.podarregloPOD 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[].urlstringURL absoluta del archivo — ver §9.5.
data.podarregloEspejo de conveniencia de currentStatus.pod (literalmente currentStatus?.pod ?? []). No es la unión de todos los eventos.
data.trackingHistoryarregloTodos 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:

  1. data.currentStatus.pod — el POD del evento que disparó esta entrega. Es lo que querés en un handler de webhook.
  2. data.pod — mismo contenido, un nivel arriba; útil si solo persistís un registro plano.
  3. 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 tiene code 5015).

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:

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

jsonc
{
  "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, no 404, con { "success": false, "message": "Orden no encontrada", … }. El mismo 401 sale 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 2xx y 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ís 429 con header Retry-After y errorCode: "RATE_LIMITED". Respetá Retry-After.
  • No consultes órdenes terminadas. Delivered y Returned to Sender son 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

  • Formatohttps://<bucket>.s3.amazonaws.com/<carpeta>/<uuid>-<epoch_ms>. En producción el bucket es dovexpress:

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

  • 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 GET tal 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/jpeg en la práctica, image/png para firmas, con fallback a image/jpeg si la app no declara nada. Como la llave no tiene extensión, tomá el formato del header Content-Type de 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, pod es []. Todo evento sin archivos lleva "pod": [], y tanto data.pod como data.currentStatus.pod son [] hasta que exista un evento con archivos. La llave siempre está presente y siempre es un arreglo — nunca null, 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 con pod: []. Solo los tres estados de §9.1 pueden llevar uno.
  • data.pod sigue al evento más reciente, no a "la entrega". Es exactamente currentStatus.pod. Como Delivered es 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 de trackingHistory cuyo code sea 5015.
  • Un evento Delivered puede tener pod vací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 pod se 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í que pod viene 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 pod tiene exactamente dos campos: type y url.
  • 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 hoyrequires_signature es false en 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 createdAt del 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 / canalDónde aparece podContenido
Webhook POST a tu URL (§5.2)data.currentStatus.pod · data.pod · data.trackingHistory[].podPOD 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[].podLos mismos tres lugares, bajo demanda
GET /api/orders/reference/:reference (§4.3)data.currentStatus.pod · data.pod · data.trackingHistory[].podIgual, buscando por tu referencia o el código DOV
GET /api/statuses (§4.4)sin pod — devuelve requires_photo / requires_signatureTe dice qué estados pueden producir POD
POST /api/orders/create (§4.1)sin podUna 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:

  1. Endpoint de catálogo de estadosGET /api/statuses (§4.4).
  2. Payload de webhook enriquecido y consistente en todos los flujos (individual + masivo), incluyendo code, reference_id, statusName, statusNameEs, createdAt.
  3. POD en el webhook en Delivered — URL(s) de archivo adjuntas automáticamente (§5.3).
  4. Gestión self-service del webhookGET/PUT/DELETE /api/webhook con upsert (§4.5).

Opcional / futuro [PROPUESTO]:

  1. Evento push status.created — notificar proactivamente a los clientes integrados cuando se agrega un nuevo estado (hoy lo detectás consultando GET /api/statuses).

11. Referencia rápida

CapacidadEstado
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étodoRutaPropósito
POST/api/orders/createCrear una orden
GET/api/orders/:idObtener orden por id interno
GET/api/orders/reference/:referenceObtener orden por tu referencia o código DOV
GET/api/statusesListar el catálogo de estados
GET/api/webhookLeer tu URL de webhook
PUT/api/webhookRegistrar / actualizar tu URL de webhook
DELETE/api/webhookEliminar tu webhook