Skip to content

DovExpress — Public API & Webhook Integration Guide (EN)

Audience: external partners/clients integrating with DovExpress for order injection, tracking, and proof-of-delivery (POD). Last reviewed against source: api/src (controllers api.ts, orders.ts, clients.ts, status.ts, drivers.ts; services webhooks.ts, s3.ts) and the live Status catalog.

Everything below is live in production unless explicitly marked [PROPOSED] (a planned gap — don't rely on it yet).

OpenAPI 3.1 spec — this API is also described by a formal specification:

The spec and this guide are published from the same source, so they don't drift apart.

Contents

  1. Overview
  2. Quickstart
  3. Authentication
  4. Endpoints
  5. Webhooks
  6. Errors
  7. Status catalog
  8. Timezone
  9. Proof of Delivery (POD)
  10. Status lifecycle & roadmap
  11. Quick reference

1. Overview

DovExpress exposes a small REST API for clients. A client is a row in the Clients table identified by a unique api_key. As a client you can:

  • Create orders for delivery.
  • Query a single order (by internal id, or by your reference / DovExpress code) with its full tracking history.
  • Receive a webhook POST on every status change, once you register a webhook URL.

The order lifecycle is driven by a shared status catalog (45 statuses in production). Status changes are produced by DovExpress operations (warehouse, distributor, driver app, admin panel) and pushed to you in real time.

  You ──POST /api/orders/create──▶  DovExpress

                       operations move the order through statuses

  Your webhook  ◀──POST {data}──────────┘   (status change, incl. POD on Delivered)

2. Quickstart

A working integration is four steps:

  1. Get your api-key from DovExpress (your sole credential — keep it secret).
  2. Register your webhook so you receive status updates:
    bash
    curl -X PUT https://api.dovexpresscr.com/api/webhook \
      -H "api-key: $DOV_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://your-app.example.com/dovexpress/webhook" }'
  3. Create an order:
    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. Track it — either wait for webhook events, or poll:
    bash
    curl https://api.dovexpresscr.com/api/orders/reference/CR0256301601 \
      -H "api-key: $DOV_API_KEY"

Sync your status catalog once at startup with GET /api/statuses (§4.4) and map by the DOV status code — never hardcode numeric codes.


3. Authentication

  • Production base URL: https://api.dovexpresscr.com

  • Credential: every request must send the header:

    api-key: <YOUR_CLIENT_API_KEY>

The API resolves the client from api-key, and the two failure modes are not the same code:

  • Header absent400 Bad Request with message: "Required" — produced by the schema validator, with data carrying the issue at path: ["headers","api-key"].
  • Unknown key (matches no client) → 401 Unauthorized with message: "Client not found".

There is no OAuth/JWT for clients — the api-key is the sole credential. Keep it server-side and secret; never expose it in browser or mobile code.

All endpoints are scoped to your client: you can only read and write your own orders.


4. Endpoints

4.1 Create order — POST /api/orders/create

Request fields

FieldTypeRequiredNotes
reference_idstringYour tracking reference. Unique per client (re-use → 409).
contactDetails.contactNamestringRecipient name.
contactDetails.contactPhonestringPrimary phone.
contactDetails.contactPhone2stringSecondary phone.
addressDetails.addressstring1..2000 chars.
addressDetails.postalCodestring
addressDetails.state / region / city / countrystring
addressDetails.lat / lngnumberCoordinates for routing.
addressDetails.notesstringDelivery notes for the driver.
packageDetails.productstring"Name - qty", or comma-separated "A - 1, B - 2".
packageDetails.quantitynumber
packageDetails.products[]arrayStructured alternative: { product, name, quantity }.
codnumberCash-on-delivery amount.
notesstringOrder-level notes.

Example body

jsonc
{
  "reference_id": "CR0256301601",          // optional; unique per client
  "contactDetails": {
    "contactName": "Juan Perez",            // required
    "contactPhone": "88888888",
    "contactPhone2": "70000000"
  },
  "addressDetails": {
    "address": "200m norte de la iglesia",  // required (1..2000 chars)
    "state": "San José",
    "region": "Central",
    "city": "Escazú",
    "country": "Costa Rica",
    "postalCode": "10203",                  // required
    "lat": 9.9281,
    "lng": -84.0907,
    "notes": "Llamar antes"
  },
  "packageDetails": {
    "product": "Shoes - 1",                 // "Name - qty", or "A - 1, B - 2"
    "quantity": 1,
    "products": [
      { "product": "SKU123", "name": "Shoes", "quantity": 1 }
    ]
  },
  "cod": 25000,
  "notes": "Fragile"
}

Behavior

  • New orders start at status Created.
  • reference_id is deduplicated per client: re-sending the same reference returns 409 Conflict.
  • DovExpress generates the internal order code (e.g. DOV_218345) — this is the tracking number shown in the tracking portal. Store it alongside your reference_id.
  • There are optional weight/dimension and quoting fields the table above does not list — see §4.1.1.

Success response — 200 OK (not 201: the response helper uses 200 on every success, creation included):

jsonc
{
  "success": true,
  "message": "Order Created",
  "data": {
    "code": "DOV_123456",                     // DovExpress tracking number
    "referenceId": "REF00000000001",          // the reference you sent, or null
    "id": "9f1c2e64-5b7a-4d3e-9a10-2c8f6b0d1a44"
    // "pricing": { … }  ← only if your account is on matrix tariff (§4.1.1)
  }
}

4.1.1 Matrix quoting (optional)

If your account is on matrix tariff, creation quotes the order and freezes the price. If it is a legacy account, none of this subsection applies: the fields stay optional and the pricing key is absent from the response.

Additional request fields — optional in the schema, but required in practice to get a quote:

FieldTypeNotes
packageDetails.weightKgnumberDeclared weight, in kg.
packageDetails.lengthCm / widthCm / heightCmnumberDimensions in cm; together with the weight they determine the package size.
payerstringWho pays the freight: SENDER (default) or RECIPIENT. With RECIPIENT the freight is added to cod to form the total collected.
districtIdstringDistrict from the DovExpress catalog; an alternative to addressDetails.postalCode for resolving the zone.
expected_pickup_datestringExpected pickup date, YYYY-MM-DD in Costa Rica time. Absent means today.

pricing object in the response — present only when the order was quoted; absent for legacy clients and in the duplicate-reference recovery response. Amounts are strings with two decimals:

jsonc
"pricing": {
  "package_size": "M",         // size resolved from weight + dimensions
  "zone_group": "GAM",         // destination zone group
  "volume_ordinal": 128,       // the order's position in your volume for the period
  "volume_tier": 2,            // volume tier that applied
  "price_base": "2500.00",     // freight before tax
  "tax_rate": "0.13",
  "tax_amount": "325.00",
  "price_total": "2825.00",    // price_base + tax_amount, exact
  "tax_included": true,
  "currency": "CRC"
}

Quoting errors — branch on errorCode. The 400 vs 422 split is deliberate: 400 means "fix your payload and retry", 422 means "the payload is fine and DovExpress cannot price it".

StatuserrorCodeWhat happened
400MEASUREMENTS_REQUIREDWeight or one of the three dimensions is missing.
400INVALID_MEASUREMENTSA weight or dimension is zero or negative.
400DISTRICT_REQUIREDThe district could not be resolved (neither postalCode nor districtId usable).
400INVALID_PAYERpayer outside SENDER / RECIPIENT.
400INVALID_PICKUP_DATEexpected_pickup_date earlier than today.
422PARCEL_NOT_QUOTABLEThe measurements fall into no tariff size.
422DISTRICT_NOT_FOUNDThe given district does not exist in the catalog.
422DISTRICT_NOT_ZONEDThe district exists but has no zone group assigned.
422ZONE_NOT_PRICEDThe zone has no price in the active tariff.
422TARIFF_NOT_FOUNDNo tariff version is active for the date.
422TARIFF_CELL_MISSINGThe size × zone × tier cell is missing from the tariff.
422PRICING_MATRIX_NOT_ENABLEDMatrix tariff is not enabled for your account.

A 422 is not fixed by retrying the same payload — it is a configuration condition on our side. Log it, alert on it separately from the 400s, and tell us.

The full detail (CreateOrderRequest and Pricing schemas, plus the 400/422 examples) lives in the OpenAPI spec.

4.2 Get order by internal id — GET /api/orders/:id

4.3 Get order by reference or code — GET /api/orders/reference/:reference

:reference matches either your reference_id or the DovExpress code. Both 4.2 and 4.3 are scoped to your client and return:

jsonc
{
  "success": true,
  "message": "Order Found",
  "data": {
    "id": "uuid",
    "code": "DOV_218345",                     // DovExpress order/tracking code
    "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 — serialized as a 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/..." } ] }
    ]
  }
}

Each trackingHistory item and currentStatus includes the DOV status code (code), the English (statusName) and Spanish (statusNameEs) names, the timestamp (createdAt), and a pod array of proof-of-delivery file URLs for that event (populated e.g. on Delivered). The order object also carries a top-level pod (the POD of the current event) and code (the DovExpress order/tracking code). trackingHistory is ordered oldest → newest. §9 documents the POD fields in full, including the complete payload and every edge case.

An unknown reference — or one that belongs to another client — returns 401 (not 404) with { "success": false, "message": "Orden no encontrada", … }.

4.4 List status catalog — GET /api/statuses

Returns the full shared catalog. Call it once at startup (and periodically) to keep your mapping in sync, including any status DovExpress adds later:

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 is the DOV status code (see §7). Map your internal states against it. Rows are returned sorted by code ascending, and retired statuses are excluded. requires_photo / requires_signature tell you which statuses produce a proof of delivery — see §9.1.

4.5 Self-service webhook management — GET / PUT / DELETE /api/webhook

Manage your own webhook endpoint with your api-key — no DovExpress intervention required:

MethodEffect
GET /api/webhookReturns { "data": { "url": "...", "updatedAt": "..." } }, or { "data": null } if none.
PUT /api/webhookBody { "url": "https://your-endpoint/hook" } → registers or updates (upsert; overwrites the previous URL).
DELETE /api/webhookRemoves your webhook.

The relation is one webhook URL per client (ClientsWebhooks, client_id unique).


5. Webhooks

5.1 Configuration

The webhook is self-service via your api-key (§4.5): PUT to register/update, GET to read, DELETE to remove. A DovExpress admin can also register it on your behalf. One webhook URL per client; PUT upserts.

5.2 Delivery & payload

On a status change, DovExpress sends an HTTP POST to your URL with:

  • Header api-keyyour own API key, the very same credential you use to call the API (§3). This is what you should validate (§5.4).
  • Header Authorization: Basic <fixed token>deprecated, still sent for backward compatibility with existing integrations. Don't build new ones on it.
  • JSON body shaped as { "data": <payload> }.

A) Canonical status-change payload (what every path sends today):

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": [] } ]
  }
}

This is the identical object the GET-order endpoints return (§4.2–4.3), documented field by field in §9.3.

B) Legacy minimal shape (defensive only):

jsonc
{ "data": { "orderId": "uuid", "statusId": "uuid", "userId": "uuid" } }

ℹ️ Bulk operations (mass assignment, cargo received) used to send this minimal body. They no longer do — every current path rebuilds the canonical payload A, so pod, code, reference_id and the status names arrive on all of them. The shape above is only reachable if an internal caller notifies without an order id, so keep a defensive branch if you already have one (resolve it via GET /api/orders/:id, §4.2, mapping statusId against your cached catalog, §4.4) but don't design around it.

5.3 Proof of delivery in the webhook — YES

The payload's trackingHistory carries a pod array per event, and the order object exposes a top-level pod for the current event. On Delivered, this contains the proof-of-delivery photo URL(s) (and signature when present), so you receive the POD automatically — no polling. The same enrichment applies to the GET-order endpoints (§4.2–4.3).

→ §9 is the full POD reference: what is captured, the end-to-end flow, the complete webhook and GET examples, the file-URL format, every edge case and the honest limitations. Read it before implementing.

5.4 Handling webhooks — recommendations

  • Verify before trusting: every notification carries an api-key header whose value is your own API key — the same one you send to https://api.dovexpresscr.com. Compare it against your key and reject anything else with 401:

    js
    if (req.headers['api-key'] !== MY_API_KEY) return res.sendStatus(401);

    Compare it in constant time if your language makes that easy, and read the key from configuration — never hard-code it. The legacy Authorization: Basic <fixed token> header is still sent for backward compatibility, but it is deprecated: it is not per-client, so it proves nothing about who called you. Validate api-key instead.

  • Defend the endpoint anyway: serve it over HTTPS only and keep the URL unguessable (e.g. a random path segment), so the credential is never the only thing standing between the internet and your handler.

  • Acknowledge fast: return HTTP 2xx as soon as you've persisted the event; do heavy work asynchronously so DovExpress isn't blocked on your processing.

  • Be idempotent: the same event may arrive more than once. Deduplicate on a stable key — e.g. id + statusId + createdAt — and make re-processing a no-op.

  • Handle both shapes: branch on the payload (rich currentStatus vs. minimal orderId/statusId/userId) and enrich shape B via the API as in §5.2.


6. Errors

The API uses conventional HTTP status codes. The codes you should handle explicitly:

Error bodies are { "success": false, "message": "…", "data": …, "status": <http status>, "code": "…", "errorCode": "…" }; errorCode is present only on the paths that define one.

data carries the error context, and on a validation 400 it is the most useful field you get: the full array of schema issues, each with its path, code and message — that is where you see exactly which payload field is wrong. On business errors (pricing, pickup date) it is null, on generic errors [], and in a few cases an object with the offending value (e.g. reference_id on a 409).

CodeWhenWhat to do
400 Bad Requestapi-key header absentmessage: "Required", with the issue in data (path: ["headers","api-key"]).Send the header on every request.
401 UnauthorizedUnknown api-keymessage: "Client not found" (or, on your side, a webhook whose api-key header doesn't match your key).Check the credential/header.
401 UnauthorizedOrder id/reference does not exist, or is not yours — message Orden no encontrada.Verify the identifier; orders are client-scoped. The API deliberately does not distinguish "absent" from "someone else's", so this is not a 404 and not a reason to rotate your key.
400 Bad RequestInvalid body — missing required field, or address outside 1..2000 chars. Also the quoting 400 family (§4.1.1).Fix the payload before retrying.
422 Unprocessable EntityThe payload is valid and DovExpress cannot quote the order (matrix-tariff clients only) — see §4.1.1.Do not retry unchanged; it is an operational condition, alert on it separately.
409 Conflictreference_id already used by your client.Treat as "already created"; do not retry blindly.
429 Too Many RequestsOver 600 requests/minute per api-key (or 3 000/minute per source IP). errorCode: "RATE_LIMITED".Honor the Retry-After response header; slow your polling loop (§9.4).
5xxTransient server error.Retry with backoff; webhook deliveries you fail to 2xx should be reconciled by polling.

Treat 4xx as your error (fix and retry deliberately) and 5xx/network errors as transient (retry with exponential backoff). For idempotent safety on creates, key on your reference_id.


7. Status catalog

All clients share one Status catalog. GET /api/statuses currently returns 45 statuses.

Don't hardcode codes. Each status has a DOV status code — a DovExpress-owned, auto-assigned, stable integer (MAX+1, starting at 5001; existing codes never change). Fetch the live catalog from GET /api/statuses (§4.4) and map against code. The table below is a snapshot for humans; the authoritative code/name/name_es always come from the API.

7.1 Catalog snapshot

Names are reproduced exactly as the API returns them, including the inconsistent capitalization and the Adress spelling — match on code, not on the string.

codenamename_esFinalPhoto
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 and 5039 are retired and excluded from GET /api/statuses; gaps in the numbering are normal and codes are never reused.

7.2 Flags that matter

Final statuses: only Delivered (5015) and Returned to Sender (5038). Once an order is final, its status can no longer change — stop polling it. Photo required when setting (requires_photo: true): Delivered (5015), Duplicate Package (5018), Rescheduled (5037). Those are the only statuses that can carry a POD — see §9.1. Signature required (requires_signature: true): none. No status in the catalog requires a signature today (§9.1). A typical lifecycle looks like Created (5014) → Cargo Received (5013) → Assigned to Driver (5009) → In Transit (5028) → Accepted (5006) → Delivered (5015) + POD photo.


8. Timezone

The API process runs with TZ='America/Costa_Rica' (UTC−6, no daylight saving). Tracking event timestamps are managed and displayed in Costa Rica time. Timestamps serialized over JSON are ISO-8601; convert to America/Costa_Rica for local display.


9. Proof of Delivery (POD)

This section is self-contained: an integration team should be able to implement POD end-to-end from here alone. Every field name, path, status code and behavior below was verified against api/src (services/webhooks.ts, controllers/api.ts, controllers/drivers.ts, services/s3.ts) and against the live status catalog.

9.1 What a POD is in DovExpress

A POD is one or more image files attached to a single tracking event. Each file has a type:

typeWhat it isCaptured when
photoPhoto taken with the driver's cameraThe status being set has requires_photo: true
signatureSignature drawn on the driver's screenThe status being set has requires_signature: true

Those two flags belong to the status catalog and are returned by GET /api/statuses (§4.4). In the live catalog, the statuses that produce a POD are:

DOV codenamename_esrequires_photorequires_signature
5015DeliveredEntregado
5018Duplicate PackagePaquete Repetido
5037RescheduledReprogramado

Every other status has both flags false and never produces a POD. In a normal delivery you therefore receive exactly one photo, on Delivered (5015).

About signatures — the honest answer: requires_signature is false for every status in the catalog today, so no signature is captured in production right now. Don't build a flow that waits for one. The capture-and-publish path is nevertheless complete (driver app → S3 → OrderFiles.file_type = 'signature'pod), so the day DovExpress enables the flag on a status, pod simply starts carrying a second entry with "type": "signature" and nothing else in the contract changes. So: iterate the array and filter by type — never assume it has exactly one element.

About "mandatory" — also honest: the requirement is enforced in the driver app, which refuses to submit a status with requires_photo: true until a photo has been captured. The endpoint the app posts to accepts the status change with or without an image. The consequence for you is in §9.6: a Delivered event produced by an operator from the admin panel (single change or a bulk warehouse operation) carries no POD, and its pod stays [].

9.2 End-to-end flow

  Driver app — driver sets "Delivered" (5015)
        │   photo (base64)  +  signature (base64, only if the status asks for it)

  Internal driver endpoint  (not part of your integration surface)

        ├─ 1. upload each image to S3  ──▶  uploads/photos/<uuid>-<epoch_ms>
        │                                   uploads/signatures/<uuid>-<epoch_ms>
        ├─ 2. write the status change  ──▶  Tracking event (status + timestamp)
        ├─ 3. link the files to it     ──▶  OrderFiles { tracking_id, file_type, file_url }
        └─ 4. only then: notify you


  Your webhook  ◀── POST { "data": { …, "pod": [ { "type": "photo", "url": … } ], … } }
                                                            (option A, §9.3)
  Your poller   ──▶ GET /api/orders/reference/:reference  → the same object
                                                            (option B, §9.4)

Step 4 running last is what guarantees the POD is already in the payload: the pod array is read back from the OrderFiles rows written in step 3.

Register your endpoint once (§4.5). From then on every status change arrives as an HTTP POST carrying the api-key header set to your own API key — validate it as in §5.4 — and a body of { "data": <payload> }. The legacy Authorization: Basic header still travels alongside it, deprecated.

This is the complete Delivered payload, exactly as the API builds it. Every value in the example is illustrative — code, reference, ids, contact, address and POD URL are fictitious; what is not illustrative is the shape:

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"
          }
        ]
      }
    ]
  }
}

Field by field:

PathTypeNotes
data.iduuid stringInternal order id — this is the :id for GET /api/orders/:id (§4.2).
data.codestringDovExpress tracking code, DOV_######.
data.reference_idstring | nullThe reference you sent on create; null if you sent none.
data.packageDetails.quantitystringStored as text, so it arrives quoted ("1").
data.codstring | nullDecimal, serialized as a string ("25000"); trailing zeros are dropped. Parse it, don't assume a JSON number.
data.currentStatusobject | nullThe newest tracking event. null only for an order with no events at all.
data.currentStatus.codenumber | nullDOV status code — 5015 is Delivered. null only if a status has no DOV code assigned.
data.currentStatus.statusIduuid stringInternal Status row id. Do not use it as a mapping key — map on code (the DOV code, dov_status_code). It is only useful to correlate with the legacy webhook shape (§5.2 B).
data.currentStatus.statusName / statusNameEsstringEN / ES names, byte-identical to GET /api/statuses.
data.currentStatus.createdAtISO-8601 stringThis is the POD timestamp — when the driver submitted the status. UTC in JSON; see §8 for the timezone.
data.currentStatus.podarrayPOD of that event. [] when the event has none. Never null, never absent.
data.currentStatus.pod[].type"photo" | "signature"Only these two values are ever emitted.
data.currentStatus.pod[].urlstringAbsolute file URL — see §9.5.
data.podarrayConvenience mirror of currentStatus.pod (literally currentStatus?.pod ?? []). Not a merge of all events.
data.trackingHistoryarrayEvery event of the order, oldest → newest, each with its own code, names, createdAt and pod.

Where to read the POD from, in order of preference:

  1. data.currentStatus.pod — the POD of the event that triggered this delivery. This is what you want in a webhook handler.
  2. data.pod — same content, one level up; handy if you only persist a flat record.
  3. data.trackingHistory[n].pod — per-event POD across the whole history; use it to backfill or to locate the POD of a specific event (e.g. the one whose code is 5015).

The order of entries inside a pod array is not guaranteed. Select by type, not by index.

9.4 Option B — polling (GET)

The exact same object is available on demand. :reference accepts your reference_id or the DovExpress code:

bash
curl -sS https://api.dovexpresscr.com/api/orders/reference/REF00000000001 \
  -H "api-key: $DOV_API_KEY"

200 OK — the response envelope wraps the same data object the webhook sends:

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) returns exactly the same body for the internal id.

Unknown reference: the API answers 401, not 404, with { "success": false, "message": "Orden no encontrada", … }. The same 401 is returned for a reference that exists but belongs to another client — the two cases are indistinguishable on purpose. Don't treat it as an auth failure and don't rotate your key over it.

Webhook or polling?

  • Webhook is the default. POD reaches you seconds after the driver submits, with one inbound call per event and no schedule to operate.
  • Poll when you have no public HTTPS endpoint; when you need to backfill orders that changed before you registered the webhook; when you failed to return 2xx and want to reconcile; or for a periodic audit that your POD archive matches ours.
  • Budget: the integration API allows 600 requests per minute per api-key (plus a 3 000/min ceiling per source IP). Over the limit you get 429 with a Retry-After header and errorCode: "RATE_LIMITED". Honor Retry-After.
  • Don't poll finished orders. Delivered and Returned to Sender are final and can never change again (§7.2) — once you've stored the POD, stop polling that order. A reconciliation loop that only walks non-final orders every few minutes stays far inside the budget.

9.5 The POD file URL

  • Formathttps://<bucket>.s3.amazonaws.com/<folder>/<uuid>-<epoch_ms>. In production the bucket is dovexpress:

    • photos → https://dovexpress.s3.amazonaws.com/uploads/photos/<uuid>-<epoch_ms>
    • signatures → https://dovexpress.s3.amazonaws.com/uploads/signatures/<uuid>-<epoch_ms>

    <uuid> is random per file and <epoch_ms> is the upload time in milliseconds. The key deliberately has no file extension.

  • Not pre-signed, no expiry. The URL carries no query string, no signature and no credentials; it is a plain object URL that you GET as-is, and it does not rotate. POD URLs are never signed by the API.

  • Readability. Objects are uploaded without a per-object ACL, so public read access comes from the bucket policy; the POD URLs DovExpress hands to partners are fetchable without credentials. If a POD URL ever answers 403, report it instead of retrying in a loop.

  • Content type. The object keeps the MIME type the driver app sent — image/jpeg in practice, image/png for signatures, falling back to image/jpeg when the app declares nothing. Since the key has no extension, take the format from the response's Content-Type header, not from the URL.

  • Size bounds at upload: smaller than 1 KB is rejected as corrupt, larger than 10 MB is rejected. A real delivery photo is a few hundred KB.

  • Recommendation: mirror the file. When you process the event, download the image and store your own copy next to your shipment record. One request makes your POD archive independent of any later bucket-policy or lifecycle change on our side, and of the soft-delete case in §9.6.

9.6 Edge cases, verified

  • Before delivery, pod is []. Every event without files carries "pod": [], and both data.pod and data.currentStatus.pod are [] until an event with files exists. The key is always present and always an array — never null, never omitted.
  • Statuses with no photo requirement never produce POD. Created, Cargo Received, Assigned to Driver, In Transit, Accepted, every attempt/exception status — all pod: []. Only the three statuses in §9.1 can carry one.
  • data.pod tracks the latest event, not "the delivery". It is exactly currentStatus.pod. Because Delivered is final nothing can be appended after it, so once delivered the two agree. If you want "the POD of the delivery" regardless of position, pick the trackingHistory entry whose code is 5015.
  • A Delivered event can legitimately have an empty pod. That happens when the status was set from the admin panel or by a bulk operation rather than the driver app (§9.1). Log it and reconcile; do not hard-fail your pipeline on a missing POD.
  • Photos not attached to a tracking event are not exposed. DovExpress staff can also attach a photo to an order from the panel; those rows have no tracking event, and pod is built strictly from a tracking event's files, so such photos never appear in the API or the webhook.
  • Deleted files disappear from later reads. POD files are filtered by "not soft-deleted". If a file is removed on our side, its entry stops appearing in subsequent responses — one more reason to mirror the image (§9.5).
  • The same event may arrive twice. Dedupe on id + statusId + createdAt (§5.4); the POD for a given event is identical across redeliveries.
  • Every webhook path now carries POD. Including the bulk operations that used to send a minimal { orderId, statusId, userId } body: they go through the same builder today, so pod is present on every delivery (see the note in §5.2).

9.7 What POD does not include

Stated explicitly so nobody has to ask:

  • No recipient identity. There is no field for who received the package — no name, no ID number, no relationship to the addressee, no "received by" text. A POD entry has exactly two fields: type and url.
  • No geolocation, device info, OTP or PIN confirmation. None of that is captured as part of POD.
  • No signature todayrequires_signature is false catalog-wide (§9.1).
  • No image metadata in the payload — no file name, size, MIME type or checksum. You learn the MIME type from the HTTP response when you fetch the URL.
  • One timestamp only: the event's createdAt, which is when the driver submitted the status. Treat it as the delivery time (§8 for the timezone).
  • No dedicated POD endpoint. There is no GET /api/orders/:id/pod. POD is only exposed embedded in the order payload — see §9.8.

9.8 POD quick reference

Endpoint / channelWhere pod appearsContent
Webhook POST to your URL (§5.2)data.currentStatus.pod · data.pod · data.trackingHistory[].podPOD of the triggering event, plus per-event POD for the whole history
GET /api/orders/:id (§4.2)data.currentStatus.pod · data.pod · data.trackingHistory[].podSame three places, on demand
GET /api/orders/reference/:reference (§4.3)data.currentStatus.pod · data.pod · data.trackingHistory[].podSame, keyed by your reference or the DOV code
GET /api/statuses (§4.4)no pod — returns requires_photo / requires_signatureTells you which statuses can produce a POD
POST /api/orders/create (§4.1)no podA new order has no tracking event with files yet

10. Status lifecycle & roadmap

When a DovExpress admin adds a new status, it is automatically assigned the next free DOV status code (MAX+1, starting at 5001) — unique and stable, so existing codes never shift. The new status immediately appears in GET /api/statuses. You stay in sync by polling that endpoint; because codes are stable, a simple diff against your stored catalog surfaces any additions.

Delivered in this integration:

  1. Status catalog endpointGET /api/statuses (§4.4).
  2. Enriched, consistent webhook payload across all paths (single + bulk), including code, reference_id, statusName, statusNameEs, createdAt.
  3. POD in the webhook on Delivered — file URL(s) attached automatically (§5.3).
  4. Self-service webhook managementGET/PUT/DELETE /api/webhook with upsert (§4.5).

Optional / future [PROPOSED]:

  1. status.created push event — proactively notify integrated clients when a new status is added (today you detect it by polling GET /api/statuses).

11. Quick reference

CapabilityStatus
Create order (POST /api/orders/create)✅ Implemented
Get order by id / reference✅ Implemented
Costa Rica timestamps (UTC−6)✅ Implemented
Webhook on status change (per client)✅ Implemented
Rich, consistent payload (single + bulk)✅ Implemented
POD photo in webhook / API (§9)✅ Implemented
Status catalog endpoint (GET /api/statuses)✅ Implemented
Self-service webhook management (GET/PUT/DELETE /api/webhook)✅ Implemented
DOV status codes (auto, unique, stable)✅ Implemented
POD signature capture⚪ Built, but no status requires it today (§9.1)
Recipient identity on POD ("received by" name / ID)❌ Not available (§9.7)
New-status push notification (status.created)❌ Optional / future

Endpoint cheat-sheet — all require the api-key header, base https://api.dovexpresscr.com:

MethodPathPurpose
POST/api/orders/createCreate an order
GET/api/orders/:idGet order by internal id
GET/api/orders/reference/:referenceGet order by your reference or DOV code
GET/api/statusesList the status catalog
GET/api/webhookRead your webhook URL
PUT/api/webhookRegister / update your webhook URL
DELETE/api/webhookRemove your webhook