Docs / API reference / CBAM
CBAM REST API
Programmatic access to the same EU CBAM scope checker, cost calculator, bulk processor, CN-code search, production-route resolver, and PDF authenticity verifier that power the in-app UI. Designed for ERP / TMS integrations: send a JSON shipment, receive the calculation + every cited EU regulation.
Customs invoice-creation API
Generating commercial invoices, packing lists, certificates of origin → that API lives at /docs/api. Different namespace ( /api/v1/invoices vs /api/v1/cbam), different auth (per-key) and rate limits.
Authentication
Most CBAM endpoints accept either anonymous calls (rate-limited by IP) or authenticated calls via a Bearer token. The bulk endpoint requires authentication and a tier with thecbam_api flag enabled (CBAM PRO / Enterprise).
The verify endpoint is the one exception — it's deliberately public (customs officers should not need an account to confirm a PDF's authenticity) and uses a different rate-limit model (per-IP + per-reportId).
# Generate an API key under /settings/api-keys (Pro+ only)
Authorization: Bearer cik_live_yourkeyhereKeys are SHA-256 hashed at rest, scoped per-permission, and revocable from /settings/api-keys. A revoked key fails closed within seconds.
Rate limits
Rate limits are tier-driven and operator-editable in /admin/tiers. The values below are read live from tier_config on every page render (1-hour ISR cache), so any change you save in the admin panel propagates here within an hour without a redeploy.
| Tier | Per-key rate | Bulk rows / call |
|---|---|---|
| Anonymous (no API key) | 10 / hour by IP | not allowed |
| Pro | 60 / hour | not allowed |
| CBAM PRO | 60 / hour | not allowed |
| Enterprise | 600 / hour | unlimited (subject to 5 000-row hard cap) |
A hard global cap of 5 000 rows per call applies regardless of tier — split larger batches across requests. Hitting any limit returns 429 (rate) or 422 (size) with a human-readable message and a Retry-After header.
CORS
Every CBAM endpoint sets:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, AuthorizationYou can call CBAM endpoints from a browser-based app on any origin. Pre-flight OPTIONS requests return 204 No Content. Bearer tokens travel in the standard Authorization header (CORS-allowed via the headers list above).
GET /api/v1/cbam/scope
Check whether a CN code is in CBAM scope per Annex I of Regulation (EU) 2023/956. Optional country and tonnage params enrich the response with NCA contact info + an authorisation-decision-tree recommendation.
Query params: cn (required, 8 digits), country (optional, ISO-2 — adds nca block when an EU member state), tonnage (optional, positive number — adds recommendation block).
GET /api/v1/cbam/scope?cn=72081000&country=DE&tonnage=120
200 OK
{
"cn_code": "72081000",
"country": "DE",
"in_scope": true,
"needs_manual_check": false,
"sector": "iron_steel",
"sector_label": "Iron and steel",
"description": "Flat-rolled products of iron or non-alloy steel...",
"nca": {
"country": "Germany",
"authority": "Deutsche Emissionshandelsstelle (DEHSt)",
"email": "...",
"website": "https://www.dehst.de/...",
"source_url": "...",
"source_accessed_at": "2026-04-29T..."
},
"recommendation": {
"needs_authorised_declarant": true,
"rationale": "..."
},
"sources": [
{ "label": "Reg (EU) 2023/956 Annex I", "url": "..." }
],
"regulation_ref": "...",
"disclosure": { ... }
}When country is omitted, the response keeps the same shape but with country: null and nca: null. Same for omitted tonnage → recommendation: null.
GET /api/v1/cbam/calculate
Single-shipment cost calculation. Returns embedded emissions, free-allocation deduction, certificate price, and net cost — every value cited to its EU regulation.
GET /api/v1/cbam/calculate
?cn=72081000
&country=TUR
&tonnage=100
&import=2026-04-29
&production_route=(A) # optional, see below
200 OK
{
"status": "ok",
"cn_code": "72081000",
"origin_country_iso3": "TUR",
"tonnage": 100,
"in_scope": true,
"sector": "iron_steel",
"production_route": "(A)",
"direct_emissions_t": 185.0,
"indirect_emissions_t": 18.3,
"free_allocation_t": 132.8,
"net_emissions_t": 52.2,
"certificate_price": {
"period": "2026-Q1",
"price_eur_per_tco2e": 75.36,
"published_at": "2026-04-07"
},
"gross_cost_eur": 3933.79,
"origin_carbon_deduction_eur": 0,
"net_cost_eur": 3933.79,
"intensity_source": "default_country_year",
"sources": [...],
"disclosure": {...}
}Optional query params:
verified_direct,verified_indirect— override default values when you have an accredited verifier's report (tCO₂e/t).origin_carbon— origin-country carbon price already paid (€/tCO₂e); deducted from gross cost per Reg 2023/956 Art. 9.production_route— EU production-route code (e.g.(A),(B),(L)). Required when the (CN, country) pair has multiple EU-published routes; use GET /routes to list them. Capped server-side at 8 chars; value beyond is truncated.
GET /api/v1/cbam/search
CN-code autocomplete. Designed for combobox UIs (the in-app calculator uses it) — returns up to 12 matches by code prefix or description fragment.
Query params: q (required, 1+ chars). Digits-only => prefix match on cn_code; letters present => case-insensitive ILIKE on description.
GET /api/v1/cbam/search?q=7208
200 OK
{
"results": [
{
"cn_code": "72081000",
"sector": "iron_steel",
"description": "Flat-rolled products of iron or non-alloy steel..."
},
{
"cn_code": "72082500",
"sector": "iron_steel",
"description": "..."
}
// up to 12 items
]
}Fallback behaviour: when an 8-digit CN returns no exact rows, the endpoint falls back to the chapter-level code (e.g. 72081000 → 72080000). Fallback results carry an extra match_kind: "chapter_fallback" field plus user_typed_cn: "72081000" so the client can render "showing chapter-level matches for…".
GET /api/v1/cbam/routes
List the EU-published production routes for a (CN, country) pair. Use this before calling /calculate when a commodity has multiple routes — the calculator needs the production_route param to pick the right intensity row.
Query params: cn (required, 8 digits), country (required, ISO-3), import (optional, ISO date or year — defaults to today).
GET /api/v1/cbam/routes?cn=72081000&country=TUR&import=2026
200 OK
Cache-Control: public, max-age=3600
{
"cn_code": "72081000",
"country_iso3": "TUR",
"year": 2026,
"routes": [
{
"production_route": "(A)",
"description": "Basic oxygen furnace (BOF)",
"value_for_year": 2.04,
"direct_t_per_t": 1.85,
"indirect_t_per_t": 0.19,
"source": "eu_default",
"match_quality": "country"
},
{
"production_route": "(B)",
"description": "Electric arc furnace (EAF)",
"value_for_year": 0.42,
"direct_t_per_t": 0.30,
"indirect_t_per_t": 0.12,
"source": "eu_default",
"match_quality": "country"
}
]
}When 0 routes are returned (no EU defaults seeded for the pair), you must supply verified_direct + verified_indirect to /calculate. When 1 route is returned, the production_route param is optional (the calculator picks it implicitly).
POST /api/v1/cbam/bulk
Bulk variant of /calculate. Designed for ERP / TMS integrations that need to price a month of imports in a single call. Returns one result per shipment plus a summary block. Authentication + cbam_api tier flag required.
Request
POST /api/v1/cbam/bulk
Content-Type: application/json
Authorization: Bearer cik_live_yourkeyhere
{
"shipments": [
{
"cn_code": "72081000",
"origin_country": "TUR", // ISO-3
"tonnage": 100,
"client_ref": "PO-2026-001", // optional, echoed back per row
"import_year": 2026, // optional, defaults to today
"verified_direct_intensity": 1.85, // optional, tCO₂e/t
"verified_indirect_intensity": 0.183, // optional, tCO₂e/t
"origin_carbon_price_eur_per_t": 5.0 // optional, €/tCO₂e
},
{
"cn_code": "76011000",
"origin_country": "CHN",
"tonnage": 50
}
]
}Response
200 OK
{
"request_id": "f3a9...", // for support / audit
"processed_at": "2026-04-30T14:22:11.123Z",
"row_count": 2,
"summary": {
"total": 2,
"in_scope": 2,
"out_of_scope": 0,
"needs_data": 0,
"errors": 0,
"total_net_cost_eur": 5183.42
},
"results": [
{
"row_index": 0,
"client_ref": "PO-2026-001",
"input": { "cn_code": "72081000", "origin_country_iso3": "TUR",
"tonnage": 100, "import_year": 2026 },
"output": {
"status": "ok",
"net_cost_eur": 3933.79,
... // same fields as /calculate
}
},
{
"row_index": 1,
"client_ref": null,
"input": {...},
"output": {...}
}
],
"disclosure": {...}
}Per-row validation
Each shipment is validated independently — a malformed row becomes a result with status: "error" and a human-readable message, but does NOT fail the whole batch. Required fields per row: cn_code, origin_country, tonnage.
Per-call limits
- Body size: 4 MB total (≈ 5 000 average rows). Exceeded →
413. - Row count: capped at your tier's
cbam_bulk_rows(see the rate-limits table above for current values). Exceeded →422. - Per-key rate limit: tier's
api_rate_per_hour. Exceeded →429.
POST /api/v1/cbam/verify
Authenticity check for CBAM PDFs issued by the platform. Customs officers and importers' auditors POST the report ID + the issued date printed on the PDF; receive back the original calculation snapshot if the pair matches.
Public endpoint, no auth required. Different rate-limit model from the rest: 30/hr per IP + 10/hr per report ID (a UUID can be guessed only by brute-forcing both the ID space and the issue date — ~36 hours of dedicated attack to scan a 1-year date window for a single ID).
POST /api/v1/cbam/verify
Content-Type: application/json
{
"reportId": "f3a9b1c2-...-...", // UUID, or literal "sample" for the marketing PDF
"issuedDate": "2026-04-07" // accepts YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY, DD.MM.YYYY
}
200 OK ── success path
{
"ok": true,
"report": {
"report_number": "CBAM-abc123de", // or "BULK-xyz-R001" for bulk per-row PDFs
"issued_at": "2026-04-07",
"cn_code": "72081000",
"origin_country_iso3": "TUR",
"tonnage": 100,
"sector": "iron_steel",
"net_cost_eur": 3933.79,
"content_hash": "9e4d...",
"download_url": "/api/cbam/report?id=...",
"is_sample": true // only when reportId === "sample"
}
}
200 OK ── failure path (NOTE: not 4xx)
{
"ok": false
}⚠ Non-standard error model
Verify deliberately returns 200 OK with { "ok": false } for every failure mode (invalid reportId, wrong date, draft/unpaid report, not-found, parse error). Uniform shape so an attacker cannot distinguish "not found" from "wrong date" via HTTP status. Only rate-limit overflow returns a real 429.
Date matching tolerates ±1 day to absorb UTC-vs-local skew. Bulk per-row PDFs are verifiable only when the parent batch status is readyAND the row's calc_status is ok or out_of_scope.
Error codes
| Status | Meaning |
|---|---|
| 400 | Malformed JSON, missing required query param, or invalid CN/country format. |
| 401 | Missing / invalid / revoked Bearer token (bulk only — the GET endpoints accept anonymous calls). |
| 402 | Tier doesn't carry the cbam_api flag. Upgrade at /pricing#cbam_pro_v2. |
| 413 | Body exceeded 4 MB (bulk). Split into multiple calls. |
| 422 | Bulk row count exceeded your tier's cbam_bulk_rows cap. |
| 429 | Per-key (or per-IP for anonymous) rate limit exceeded. Retry after the value in the Retry-After header. |
| 500 | Calculator threw on every row. Treat as a transient outage; the same payload will succeed on retry once the underlying source is back. |
| 200 + ok:false | Verify endpoint only. Failure modes (not-found, wrong date, draft) are reported as 200 OK with { "ok": false } — see the verify section above. |
Examples
curl
curl -X POST https://customs-invoice.com/api/v1/cbam/bulk \
-H "Authorization: Bearer cik_live_yourkeyhere" \
-H "Content-Type: application/json" \
-d '{
"shipments": [
{ "cn_code": "72081000", "origin_country": "TUR", "tonnage": 100,
"client_ref": "PO-2026-001" },
{ "cn_code": "76011000", "origin_country": "CHN", "tonnage": 50,
"client_ref": "PO-2026-002" }
]
}'TypeScript / Node
const res = await fetch("https://customs-invoice.com/api/v1/cbam/bulk", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CUSTOMS_INVOICE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
shipments: rowsFromErp.map((r) => ({
cn_code: r.hsCode,
origin_country: r.origin,
tonnage: r.netWeightKg / 1000,
client_ref: r.purchaseOrder,
import_year: new Date(r.importDate).getUTCFullYear(),
})),
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`CBAM bulk failed (${res.status}): ${error}`);
}
const { results, summary } = await res.json();
console.log(`Total CBAM cost: €${summary.total_net_cost_eur}`);Python
import requests, os
resp = requests.post(
"https://customs-invoice.com/api/v1/cbam/bulk",
headers={
"Authorization": f"Bearer {os.environ['CUSTOMS_INVOICE_API_KEY']}",
"Content-Type": "application/json",
},
json={
"shipments": [
{"cn_code": "72081000", "origin_country": "TUR", "tonnage": 100,
"client_ref": "PO-2026-001"},
{"cn_code": "76011000", "origin_country": "CHN", "tonnage": 50},
],
},
timeout=60,
)
resp.raise_for_status()
body = resp.json()
print(f"Total CBAM cost: €{body['summary']['total_net_cost_eur']}")Disclosure + audit
Every response carries a disclosure block stating that the calculation is informational and does not replace your CBAM authorisation, NCA, or accredited verifier. Each row is also logged to our internal audit table tagged with the request_id so a regulator query can be traced back to the exact API call. See the full CBAM disclosure.
Status + changelog
v1 endpoints are stable. Breaking changes ship as v2 with a 12-month dual-run period. Track changes at /docs/changelog or watch our GitHub releases.