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.

Base: https://ecmrcapture.comStableJSON onlyEU data residency

Quickstart

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_QkVhSmRQVi1WV0VYbW1ZS0M3a3lEUk9G
Never embed a key in browser code. Treat it like a database password.

Rate limits

Per-key sliding-hour buckets. Plan-gated.

PlanRequests / hourBurst behaviour
Solo (€99)200Hard cap; 429 with Retry-After
Fleet (€249)1,000Hard cap; 429 with Retry-After
Forwarder (€499)Effectively unlimitedSoft cap, contact us if you cross 50 rps sustained
ConciergeEffectively unlimitedSame as Forwarder

Every response carries:

httpRateLimit-Limit: 200
RateLimit-Remaining: 187
RateLimit-Reset: 1715000000

POST /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

ParamTypeNotes
limitinteger1–100, default 20
statusstringpending · processing · completed · low_confidence · error
starting_afteruuidReturned 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:

EventFired when
extraction.completedAll required fields ≥ 90 % confidence
extraction.low_confidenceAt least one required field &lt; 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"
  }
}
HTTPtypeWhen you see it
400invalid_requestBody validation failed (Zod)
401missing_authenticationAuthorization header absent
401invalid_api_keyUnknown or malformed key
401revoked_api_keyKey was revoked in the dashboard
404not_foundResource id does not belong to your org
413payload_too_largeFile over 10 MB sync / 25 MB async
415unsupported_media_typeUse jpg/png/webp/gif (PDF coming Q3)
429rate_limit_exceededPlan limit hit; check Retry-After header
500internal_errorBug. Open one — we'll fix it within a day.
502extraction_failedUpstream 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.

BoxField keyType
1box_01_senderParty
2box_02_consigneeParty
3box_03_place_of_deliveryPlaceWithDate
4box_04_place_of_taking_overPlaceWithDate
5box_05_documents_attachedstring[]
6box_06_marks_and_numbersstring
7box_07_number_of_packagesinteger
8box_08_method_of_packingstring
9box_09_nature_of_goodsstring
10box_10_statistical_numberstring (HS code)
11box_11_gross_weight_kgnumber
12box_12_volume_m3number
13box_13_senders_instructionsstring
14box_14_return_freightstring
15box_15_cash_on_delivery{amount, currency}
16box_16_carrierParty
17box_17_successive_carrierParty
18box_18_carriers_reservationsstring
19box_19_special_agreementsstring
20box_20_to_be_paid_by{sender_pays, consignee_pays, …}
21box_21_drawn_up_atPlaceWithDate
22box_22_signature_of_senderSignature
23box_23_signature_of_carrierSignature
24box_24_signature_of_consigneeSignature

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.