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(controllersapi.ts,orders.ts,clients.ts,status.ts,drivers.ts; serviceswebhooks.ts,s3.ts) and the liveStatuscatalog.
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:
- Interactive reference: https://docs.dovexpresscr.com/reference/ (browse schemas and try the endpoints).
- Raw spec: https://docs.dovexpresscr.com/openapi.yaml — import it into Postman or Insomnia, or generate clients with
openapi-generator,openapi-typescript, etc.The spec and this guide are published from the same source, so they don't drift apart.
Contents
- Overview
- Quickstart
- Authentication
- Endpoints
- Webhooks
- Errors
- Status catalog
- Timezone
- Proof of Delivery (POD)
- Status lifecycle & roadmap
- 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
POSTon 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:
- Get your
api-keyfrom DovExpress (your sole credential — keep it secret). - 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" }' - 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" } }' - 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.comCredential: 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 absent →
400 Bad Requestwithmessage: "Required"— produced by the schema validator, withdatacarrying the issue atpath: ["headers","api-key"]. - Unknown key (matches no client) →
401 Unauthorizedwithmessage: "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
| Field | Type | Required | Notes |
|---|---|---|---|
reference_id | string | – | Your tracking reference. Unique per client (re-use → 409). |
contactDetails.contactName | string | ✅ | Recipient name. |
contactDetails.contactPhone | string | – | Primary phone. |
contactDetails.contactPhone2 | string | – | Secondary phone. |
addressDetails.address | string | ✅ | 1..2000 chars. |
addressDetails.postalCode | string | ✅ | |
addressDetails.state / region / city / country | string | – | |
addressDetails.lat / lng | number | – | Coordinates for routing. |
addressDetails.notes | string | – | Delivery notes for the driver. |
packageDetails.product | string | – | "Name - qty", or comma-separated "A - 1, B - 2". |
packageDetails.quantity | number | – | |
packageDetails.products[] | array | – | Structured alternative: { product, name, quantity }. |
cod | number | – | Cash-on-delivery amount. |
notes | string | – | Order-level notes. |
Example body
{
"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_idis deduplicated per client: re-sending the same reference returns409 Conflict.- DovExpress generates the internal order
code(e.g.DOV_218345) — this is the tracking number shown in the tracking portal. Store it alongside yourreference_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):
{
"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:
| Field | Type | Notes |
|---|---|---|
packageDetails.weightKg | number | Declared weight, in kg. |
packageDetails.lengthCm / widthCm / heightCm | number | Dimensions in cm; together with the weight they determine the package size. |
payer | string | Who pays the freight: SENDER (default) or RECIPIENT. With RECIPIENT the freight is added to cod to form the total collected. |
districtId | string | District from the DovExpress catalog; an alternative to addressDetails.postalCode for resolving the zone. |
expected_pickup_date | string | Expected 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:
"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".
| Status | errorCode | What happened |
|---|---|---|
400 | MEASUREMENTS_REQUIRED | Weight or one of the three dimensions is missing. |
400 | INVALID_MEASUREMENTS | A weight or dimension is zero or negative. |
400 | DISTRICT_REQUIRED | The district could not be resolved (neither postalCode nor districtId usable). |
400 | INVALID_PAYER | payer outside SENDER / RECIPIENT. |
400 | INVALID_PICKUP_DATE | expected_pickup_date earlier than today. |
422 | PARCEL_NOT_QUOTABLE | The measurements fall into no tariff size. |
422 | DISTRICT_NOT_FOUND | The given district does not exist in the catalog. |
422 | DISTRICT_NOT_ZONED | The district exists but has no zone group assigned. |
422 | ZONE_NOT_PRICED | The zone has no price in the active tariff. |
422 | TARIFF_NOT_FOUND | No tariff version is active for the date. |
422 | TARIFF_CELL_MISSING | The size × zone × tier cell is missing from the tariff. |
422 | PRICING_MATRIX_NOT_ENABLED | Matrix 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:
{
"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:
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 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:
| Method | Effect |
|---|---|
GET /api/webhook | Returns { "data": { "url": "...", "updatedAt": "..." } }, or { "data": null } if none. |
PUT /api/webhook | Body { "url": "https://your-endpoint/hook" } → registers or updates (upsert; overwrites the previous URL). |
DELETE /api/webhook | Removes 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-key— your 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):
{
"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):
{ "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_idand 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 viaGET /api/orders/:id, §4.2, mappingstatusIdagainst 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-keyheader whose value is your own API key — the same one you send tohttps://api.dovexpresscr.com. Compare it against your key and reject anything else with401:jsif (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. Validateapi-keyinstead.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
2xxas 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
currentStatusvs. minimalorderId/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).
| Code | When | What to do |
|---|---|---|
400 Bad Request | api-key header absent — message: "Required", with the issue in data (path: ["headers","api-key"]). | Send the header on every request. |
401 Unauthorized | Unknown api-key — message: "Client not found" (or, on your side, a webhook whose api-key header doesn't match your key). | Check the credential/header. |
401 Unauthorized | Order 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 Request | Invalid 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 Entity | The 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 Conflict | reference_id already used by your client. | Treat as "already created"; do not retry blindly. |
429 Too Many Requests | Over 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). |
5xx | Transient server error. | Retry with backoff; webhook deliveries you fail to 2xx should be reconciled by polling. |
Treat
4xxas your error (fix and retry deliberately) and5xx/network errors as transient (retry with exponential backoff). For idempotent safety on creates, key on yourreference_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 at5001; existing codes never change). Fetch the live catalog fromGET /api/statuses(§4.4) and map againstcode. The table below is a snapshot for humans; the authoritativecode/name/name_esalways 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.
| code | name | name_es | Final | Photo |
|---|---|---|---|---|
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 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 likeCreated (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:
type | What it is | Captured when |
|---|---|---|
photo | Photo taken with the driver's camera | The status being set has requires_photo: true |
signature | Signature drawn on the driver's screen | The 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 code | name | name_es | requires_photo | requires_signature |
|---|---|---|---|---|
5015 | Delivered | Entregado | ✅ | – |
5018 | Duplicate Package | Paquete Repetido | ✅ | – |
5037 | Rescheduled | Reprogramado | ✅ | – |
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.
9.3 Option A — webhook (push; recommended)
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:
{
"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:
| Path | Type | Notes |
|---|---|---|
data.id | uuid string | Internal order id — this is the :id for GET /api/orders/:id (§4.2). |
data.code | string | DovExpress tracking code, DOV_######. |
data.reference_id | string | null | The reference you sent on create; null if you sent none. |
data.packageDetails.quantity | string | Stored as text, so it arrives quoted ("1"). |
data.cod | string | null | Decimal, serialized as a string ("25000"); trailing zeros are dropped. Parse it, don't assume a JSON number. |
data.currentStatus | object | null | The newest tracking event. null only for an order with no events at all. |
data.currentStatus.code | number | null | DOV status code — 5015 is Delivered. null only if a status has no DOV code assigned. |
data.currentStatus.statusId | uuid string | Internal 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 / statusNameEs | string | EN / ES names, byte-identical to GET /api/statuses. |
data.currentStatus.createdAt | ISO-8601 string | This is the POD timestamp — when the driver submitted the status. UTC in JSON; see §8 for the timezone. |
data.currentStatus.pod | array | POD 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[].url | string | Absolute file URL — see §9.5. |
data.pod | array | Convenience mirror of currentStatus.pod (literally currentStatus?.pod ?? []). Not a merge of all events. |
data.trackingHistory | array | Every 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:
data.currentStatus.pod— the POD of the event that triggered this delivery. This is what you want in a webhook handler.data.pod— same content, one level up; handy if you only persist a flat record.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 whosecodeis5015).
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:
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:
{
"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, not404, with{ "success": false, "message": "Orden no encontrada", … }. The same401is 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
2xxand 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 get429with aRetry-Afterheader anderrorCode: "RATE_LIMITED". HonorRetry-After. - Don't poll finished orders.
DeliveredandReturned to Senderare 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
Format —
https://<bucket>.s3.amazonaws.com/<folder>/<uuid>-<epoch_ms>. In production the bucket isdovexpress:- 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.- photos →
Not pre-signed, no expiry. The URL carries no query string, no signature and no credentials; it is a plain object URL that you
GETas-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/jpegin practice,image/pngfor signatures, falling back toimage/jpegwhen the app declares nothing. Since the key has no extension, take the format from the response'sContent-Typeheader, 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,
podis[]. Every event without files carries"pod": [], and bothdata.podanddata.currentStatus.podare[]until an event with files exists. The key is always present and always an array — nevernull, never omitted. - Statuses with no photo requirement never produce POD.
Created,Cargo Received,Assigned to Driver,In Transit,Accepted, every attempt/exception status — allpod: []. Only the three statuses in §9.1 can carry one. data.podtracks the latest event, not "the delivery". It is exactlycurrentStatus.pod. BecauseDeliveredis 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 thetrackingHistoryentry whosecodeis5015.- A
Deliveredevent can legitimately have an emptypod. 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
podis 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, sopodis 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:
typeandurl. - No geolocation, device info, OTP or PIN confirmation. None of that is captured as part of POD.
- No signature today —
requires_signatureisfalsecatalog-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 / channel | Where pod appears | Content |
|---|---|---|
Webhook POST to your URL (§5.2) | data.currentStatus.pod · data.pod · data.trackingHistory[].pod | POD 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[].pod | Same three places, on demand |
GET /api/orders/reference/:reference (§4.3) | data.currentStatus.pod · data.pod · data.trackingHistory[].pod | Same, keyed by your reference or the DOV code |
GET /api/statuses (§4.4) | no pod — returns requires_photo / requires_signature | Tells you which statuses can produce a POD |
POST /api/orders/create (§4.1) | no pod | A 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:
- ✅ Status catalog endpoint —
GET /api/statuses(§4.4). - ✅ Enriched, consistent webhook payload across all paths (single + bulk), including
code,reference_id,statusName,statusNameEs,createdAt. - ✅ POD in the webhook on
Delivered— file URL(s) attached automatically (§5.3). - ✅ Self-service webhook management —
GET/PUT/DELETE /api/webhookwith upsert (§4.5).
Optional / future [PROPOSED]:
status.createdpush event — proactively notify integrated clients when a new status is added (today you detect it by pollingGET /api/statuses).
11. Quick reference
| Capability | Status |
|---|---|
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:
| Method | Path | Purpose |
|---|---|---|
POST | /api/orders/create | Create an order |
GET | /api/orders/:id | Get order by internal id |
GET | /api/orders/reference/:reference | Get order by your reference or DOV code |
GET | /api/statuses | List the status catalog |
GET | /api/webhook | Read your webhook URL |
PUT | /api/webhook | Register / update your webhook URL |
DELETE | /api/webhook | Remove your webhook |