eCMR Capture · v1
REST API documentation
Extract the 24 canonical CMR fields from photos, scans, and PDFs. Synchronous and webhook-driven flows. Idempotent reads. Bearer auth with HMAC-signed webhooks. OpenAPI 3.1 spec served at /openapi.json.
https://ecmrcapture.comStableJSON onlyEU data residencyQuickstart
Three commands take a CMR image to structured JSON.
bash# 1. Get a key from the dashboard.
# https://ecmrcapture.com/dashboard/api-keys
# 2. Extract a CMR (sync, ≤10 MB):
curl -s https://ecmrcapture.com/v1/extractions \
-H "Authorization: Bearer ek_live_…" \
-F "file=@cmr.jpg"
# 3. The response is the parsed extraction. Done.For files larger than 10 MB or batch ingestion, use the async endpoint and subscribe a webhook.
Authentication
Every request requires Authorization: Bearer ek_live_<…>. Generate keys in the dashboard. Tokens are shown once on creation; we store only the SHA-256 hash, so a lost key must be rotated.
Keys are scoped to your organization. Revoking is immediate.
httpAuthorization: Bearer ek_live_QkVhSmRQVi1WV0VYbW1ZS0M3a3lEUk9GRate limits
Per-key sliding-hour buckets. Plan-gated.
| Plan | Requests / hour | Burst behaviour |
|---|---|---|
| Solo (€99) | 200 | Hard cap; 429 with Retry-After |
| Fleet (€249) | 1,000 | Hard cap; 429 with Retry-After |
| Forwarder (€499) | Effectively unlimited | Soft cap, contact us if you cross 50 rps sustained |
| Concierge | Effectively unlimited | Same as Forwarder |
Every response carries:
httpRateLimit-Limit: 200
RateLimit-Remaining: 187
RateLimit-Reset: 1715000000POST /v1/extractions
Synchronous extraction. Multipart upload, single file field, ≤ 10 MB. Returns the parsed extraction inline. Typical latency 8–15 seconds.
Request
bashcurl -s https://ecmrcapture.com/v1/extractions \
-H "Authorization: Bearer ek_live_…" \
-F "file=@cmr.jpg"javascriptconst fd = new FormData();
fd.append("file", fs.createReadStream("cmr.jpg"));
const res = await fetch("https://ecmrcapture.com/v1/extractions", {
method: "POST",
headers: { Authorization: "Bearer " + process.env.ECMR_KEY },
body: fd,
});
const extraction = await res.json();pythonimport os, requests
with open("cmr.jpg", "rb") as f:
res = requests.post(
"https://ecmrcapture.com/v1/extractions",
headers={"Authorization": f"Bearer {os.environ['ECMR_KEY']}"},
files={"file": f},
timeout=60,
)
extraction = res.json()Response — 200 OK
json{
"object": "extraction",
"id": "9f1b8a3e-6c2d-4f0a-8b7d-1e2f3c4d5e6a",
"status": "completed",
"confidence": 0.94,
"fields": {
"box_01_sender": {
"value": { "name": "UAB Vežimai", "address": "Kauno pl. 12, Vilnius", "country": "LT", "vat_id": "LT100012345678" },
"confidence": 0.98,
"source": "extracted"
},
"box_07_number_of_packages": { "value": 12, "confidence": 1.0, "source": "extracted" },
"box_11_gross_weight_kg": { "value": 1450, "confidence": 0.99, "source": "extracted" },
"warnings": []
},
"file": { "mime": "image/jpeg", "size_bytes": 542318 },
"created_at": "2026-05-03T18:24:11.024Z",
"updated_at": "2026-05-03T18:24:19.402Z"
}POST /v1/extractions/async
Same shape, but returns 202 immediately. Up to 25 MB. We process the file in the background and POST a signed event to every subscribed webhook endpoint when finished.
bashcurl -i https://ecmrcapture.com/v1/extractions/async \
-H "Authorization: Bearer ek_live_…" \
-F "file=@big-pdf-scan.pdf"
HTTP/2 202
{
"object": "extraction",
"id": "9f1b8a3e-…",
"status": "pending",
"poll_url": "/v1/extractions/9f1b8a3e-…"
}GET /v1/extractions/{id}
Idempotent re-read. Use as the polling fallback when a webhook hasn't fired yet.
bashcurl https://ecmrcapture.com/v1/extractions/9f1b8a3e-… \
-H "Authorization: Bearer ek_live_…"GET /v1/extractions
Paginated by created_at desc. Cursor pagination via starting_after.
bashcurl "https://ecmrcapture.com/v1/extractions?limit=25&status=completed" \
-H "Authorization: Bearer ek_live_…"Query parameters
| Param | Type | Notes |
|---|---|---|
| limit | integer | 1–100, default 20 |
| status | string | pending · processing · completed · low_confidence · error |
| starting_after | uuid | Returned as next_cursor in the previous page |
POST /v1/webhooks
Register an HTTPS endpoint to receive extraction events. The signing secret is returned exactly once.
bashcurl -s https://ecmrcapture.com/v1/webhooks \
-H "Authorization: Bearer ek_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/ecmr",
"description": "Production TMS sink",
"events": ["extraction.completed", "extraction.low_confidence"]
}'json{
"object": "webhook_endpoint",
"id": "b7c1…",
"url": "https://api.example.com/webhooks/ecmr",
"events": ["extraction.completed", "extraction.low_confidence"],
"status": "active",
"consecutive_failures": 0,
"signing_secret": "whsec_a1b2c3d4…"
}List with GET /v1/webhooks. Disable with DELETE /v1/webhooks/{id} (soft delete).
Webhook event format
We POST a JSON envelope to every subscribed endpoint. Event types:
| Event | Fired when |
|---|---|
| extraction.completed | All required fields ≥ 90 % confidence |
| extraction.low_confidence | At least one required field < 90 %; review needed |
jsonPOST https://api.example.com/webhooks/ecmr
Content-Type: application/json
User-Agent: eCMRCapture/1.0 (+https://ecmrcapture.com/docs)
X-eCMR-Signature: t=1715000000,v1=2c4a…7f2
{
"id": "evt_3a8c…",
"type": "extraction.completed",
"created": 1715000000,
"data": {
"extraction": { "object": "extraction", "id": "9f1b8a3e-…", "status": "completed", "confidence": 0.94, "fields": { /* … */ } }
}
}Respond with any 2xx within 10 seconds. Non-2xx triggers retries with exponential backoff (5 attempts). After 10 consecutive failures we auto-disable the endpoint.
Verifying webhook signatures
Every delivery includes X-eCMR-Signature: t=<unix>,v1=<hex_hmac>. Compute HMAC-SHA256(secret, t + "." + raw_body) over the raw body bytes (before any JSON parse) and compare in constant time. Reject if t is more than 5 minutes off.
javascriptimport { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, rawBody, header) {
const parts = Object.fromEntries(
header.split(",").map(kv => {
const i = kv.indexOf("=");
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}),
);
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now()/1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}pythonimport hmac, hashlib, time
def verify(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{t}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Error reference
All errors share one shape:
json{
"error": {
"type": "invalid_api_key",
"message": "Unknown API key. It may have been revoked or never existed.",
"doc_url": "https://ecmrcapture.com/docs#authentication"
}
}| HTTP | type | When you see it |
|---|---|---|
| 400 | invalid_request | Body validation failed (Zod) |
| 401 | missing_authentication | Authorization header absent |
| 401 | invalid_api_key | Unknown or malformed key |
| 401 | revoked_api_key | Key was revoked in the dashboard |
| 404 | not_found | Resource id does not belong to your org |
| 413 | payload_too_large | File over 10 MB sync / 25 MB async |
| 415 | unsupported_media_type | Use jpg/png/webp/gif (PDF coming Q3) |
| 429 | rate_limit_exceeded | Plan limit hit; check Retry-After header |
| 500 | internal_error | Bug. Open one — we'll fix it within a day. |
| 502 | extraction_failed | Upstream model error; safe to retry |
The 24-field schema
Every extraction returns 24 boxes per the UNECE 1956 CMR Convention, plus a warnings array. Each field has the shape { value, confidence, source }. Required boxes per Art. 6: 1, 2, 3, 4, 7, 8, 9, 11, 16, 21, 22, 23, 24.
| Box | Field key | Type |
|---|---|---|
| 1 | box_01_sender | Party |
| 2 | box_02_consignee | Party |
| 3 | box_03_place_of_delivery | PlaceWithDate |
| 4 | box_04_place_of_taking_over | PlaceWithDate |
| 5 | box_05_documents_attached | string[] |
| 6 | box_06_marks_and_numbers | string |
| 7 | box_07_number_of_packages | integer |
| 8 | box_08_method_of_packing | string |
| 9 | box_09_nature_of_goods | string |
| 10 | box_10_statistical_number | string (HS code) |
| 11 | box_11_gross_weight_kg | number |
| 12 | box_12_volume_m3 | number |
| 13 | box_13_senders_instructions | string |
| 14 | box_14_return_freight | string |
| 15 | box_15_cash_on_delivery | {amount, currency} |
| 16 | box_16_carrier | Party |
| 17 | box_17_successive_carrier | Party |
| 18 | box_18_carriers_reservations | string |
| 19 | box_19_special_agreements | string |
| 20 | box_20_to_be_paid_by | {sender_pays, consignee_pays, …} |
| 21 | box_21_drawn_up_at | PlaceWithDate |
| 22 | box_22_signature_of_sender | Signature |
| 23 | box_23_signature_of_carrier | Signature |
| 24 | box_24_signature_of_consignee | Signature |
OpenAPI 3.1 spec
Machine-readable spec for SDK generation: https://ecmrcapture.com/openapi.json.
Tested with openapi-typescript, Swagger UI, and Postman. Drop the URL into your generator of choice.