A CDR (Charge Detail Record) is OCPI’s billing artifact. After every completed charging session, the CPO generates one CDR and sends it to the eMSP. The eMSP uses the CDR to charge the driver’s payment method, file the transaction for accounting, and audit pricing.
What follows is the object itself, then the rules that keep it trustworthy once it has left the CPO.
The CDR object
A CDR is structurally similar to a completed Session but with billing-specific fields. This is a field-name map of the object; every nested object (cdr_token, cdr_location, tariffs, charging_periods, each Price) is shown fully populated in the complete worked example further down:
{
"country_code": "NL", // ISO 3166-1 alpha-2 of the CPO
"party_id": "ABC", // the CPO's party id
"id": "CDR_98765", // CDR id, unique within this CPO
"start_date_time": "2026-10-01T10:30:00Z",
"end_date_time": "2026-10-01T10:55:23Z",
"session_id": "SESSION_12345", // link back to the Session
"cdr_token": { }, // CdrToken: full shape in the worked example
"auth_method": "AUTH_REQUEST", // AUTH_REQUEST | COMMAND | WHITELIST
"authorization_reference": "AUTHREF_00042", // the eMSP's authorization id, if any
"cdr_location": { }, // CdrLocation snapshot: full shape in the worked example
"meter_id": null, // physical meter id, or null
"currency": "EUR",
"tariffs": [ ], // Tariff[] snapshot: full shape in the worked example
"charging_periods": [ ], // ChargingPeriod[]: full shape in the worked example
"signed_data": null, // optional cryptographic signing
"total_cost": {
"excl_vat": 11.52,
"incl_vat": 13.82
},
"total_fixed_cost": { "excl_vat": 0.00, "incl_vat": 0.00 },
"total_energy": 28.7,
"total_energy_cost": { "excl_vat": 10.05, "incl_vat": 12.05 },
"total_time": 0.42, // hours
"total_time_cost": { "excl_vat": 1.22, "incl_vat": 1.46 },
"total_parking_time": 0.05, // hours
"total_parking_cost": { "excl_vat": 0.25, "incl_vat": 0.31 },
"total_reservation_cost": null,
"remark": null,
"invoice_reference_id": "INV-2026-10-0042",
"credit": false, // true if this is a correction
"credit_reference_id": null, // links to the original CDR if credit=true
"home_charging_compensation": false, // true only for home-charging reimbursement CDRs
"last_updated": "2026-10-01T10:55:30Z"
}
The structure is more detailed than a Session because it’s the final accounting:
- Multiple
total_*fields break down the cost by component (energy, time, fixed, parking) - The Tariffs used are snapshotted, so the CDR never depends on a live Tariff that may have changed since
- The Location data is snapshotted too, as is the Token that authorized the charge
- The CDR ID is different from the Session ID; a
session_idfield carries the link
Immutability and credits
CDRs are immutable. If a CPO needs to correct one (an error was found, a refund is owed), they don’t modify the original. Instead, they publish a credit CDR:
flowchart LR
S[Session ends] --> C1[Original CDR<br/>total_cost 13.82 EUR<br/>credit false]
C1 --> D{Error found?}
D -->|No| Bill[Bill customer<br/>13.82 EUR]
D -->|Yes| C2[Credit CDR<br/>total_cost -3.60 EUR<br/>credit true<br/>credit_reference_id: original]
C2 --> Net[Net customer charge<br/>10.22 EUR<br/>both records preserved]
style C1 fill:#dbeafe,stroke:#2563eb
style C2 fill:#fee2e2,stroke:#dc2626
style Net fill:#dcfce7,stroke:#16a34a
A credit CDR is a full CDR object with the same required fields as any other, shown complete in the gotchas section below. Only these fields differ from the original:
{
// country_code, party_id, a NEW id, start/end times, cdr_token, auth_method,
// cdr_location, currency, tariffs, charging_periods, the total_* fields and
// last_updated all still apply, same shape as any CDR. Only the four below change:
"credit": true,
"credit_reference_id": "CDR_98765", // the original being corrected
"total_cost": {
"excl_vat": -3.00,
"incl_vat": -3.60
},
"remark": "Adjustment for overcharge: peak rate applied during off-peak window"
}
The original CDR stays. The credit CDR sits alongside it. The net effect on the customer’s account is original + credit. That preserves the audit trail: both the original, with its error intact, and the correction stay visible.
How CDRs are sent
CPOs push CDRs to eMSPs via the Receiver Interface. The eMSP receives POST requests:
POST /ocpi/emsp/2.2.1/cdrs
Authorization: Token <CPO credentials>
{
// the complete CDR object. See the worked example below for every
// field populated
}
CDRs use POST (not PUT) because each CDR is created once and never updated. The eMSP confirms receipt with a 201 Created. The credentials token in that header is the one the eMSP issued to this specific CPO during the Credentials handshake, so it is not interchangeable with the token going the other way.
If the eMSP doesn’t acknowledge (network issue, downtime), the CPO retries. CDR delivery has to be reliable, because billing depends on it.
A complete worked example: CPO posts, eMSP reads
The snippets above are trimmed for clarity. Here is the full round trip: the
CPO posting a CDR, then the eMSP reading CDRs back. The wire-level details that
trip people up are called out in the comments. (Comments use // and # for
teaching; real JSON has no comments.)
1. The CPO posts a CDR (POST to the eMSP)
# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# The CPO owns the billing record, so it PUSHES the full CDR to the eMSP's
# Receiver Interface. It is POST (create-once), NOT PUT/PATCH. A CDR is
# immutable: there is no id in the URL because you are creating a new resource,
# and you never resend or modify it. To correct one you POST a NEW credit CDR
# (see the gotchas section). You do not touch this one.
POST /ocpi/emsp/2.2.1/cdrs HTTP/1.1
Host: emsp.example.com
# Auth scheme is the literal word "Token", NOT "Bearer", then the credentials
# token the eMSP issued to THIS CPO during the Credentials handshake.
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json
# Recommended so both sides can trace one call across their logs; echo them back.
X-Request-ID: 12345
X-Correlation-ID: 67890
{
"country_code": "NL", // ISO 3166-1 alpha-2 of the CPO (2 chars)
"party_id": "TNM", // the CPO's party id (3 chars)
"id": "CDR0001", // CDR id, unique within this CPO. NOT the session id.
"start_date_time": "2026-07-03T10:30:00Z", // RFC 3339, UTC ("Z"): when the session started
"end_date_time": "2026-07-03T10:55:23Z", // when it ended; a CDR is only made once the session is done
"session_id": "SESSION0001", // link back to the Session this bills for
"cdr_token": { // snapshot of the token that authorized the charge
"country_code": "NL",
"party_id": "TNM",
"uid": "12345678905880",
"type": "RFID",
"contract_id": "NL-TNM-C12345678-X"
},
"auth_method": "AUTH_REQUEST", // how the charge was authorized (AUTH_REQUEST | COMMAND | WHITELIST)
"cdr_location": { // SNAPSHOT of the station as it was at charge time,
"id": "LOC1", // not a live reference. If the site changes later,
"name": "Amsterdam Central Garage", // the CDR still shows where this session happened.
"address": "Stationsplein 1",
"city": "Amsterdam",
"postal_code": "1012 AB", // optional
"state": null, // optional: state/province, mostly used in US/CA
"country": "NLD", // gotcha: alpha-3 here (3 letters)
"coordinates": { "latitude": "52.379189", "longitude": "4.899431" },
"evse_uid": "3256",
"evse_id": "NL*TNM*E*001",
"connector_id": "1",
"connector_standard": "IEC_62196_T2",
"connector_format": "SOCKET",
"connector_power_type": "AC_3_PHASE"
},
"authorization_reference": "AUTHREF0001", // optional: the eMSP's authorization id, if the CPO asked it to authorize
"meter_id": "METER-AMS-3256-01", // optional: the physical meter that recorded this session
"currency": "EUR", // required: ISO 4217
"tariffs": [ // optional but recommended: SNAPSHOT of the tariff(s) used, frozen at
{ // charge time so the CDR never depends on the live (mutable) Tariff.
"country_code": "NL", // required: ISO 3166-1 alpha-2 of the CPO that owns the tariff
"party_id": "TNM", // required: the CPO's party id
"id": "TARIFF0001", // required: tariff id, unique within this CPO
"currency": "EUR", // required: ISO 4217
"type": "REGULAR", // optional: AD_HOC_PAYMENT | PROFILE_CHEAP | PROFILE_FAST | PROFILE_GREEN | REGULAR
"tariff_alt_text": [ // optional: human-readable tariff description(s), one per language
{ "language": "en", "text": "0.35 EUR/kWh, 5 EUR/h parking after charging" }
],
"tariff_alt_url": "https://tnm.example.com/tariffs/TARIFF0001", // optional: web page explaining the tariff
"min_price": { "excl_vat": 0.50, "incl_vat": 0.60 }, // optional: floor cost per session
"max_price": { "excl_vat": 50.00, "incl_vat": 60.00 }, // optional: cap cost per session
"elements": [ // required (>=1): the actual price rules
{
"price_components": [ // required (>=1): each is one priced dimension
{ "type": "ENERGY", "price": 0.35, "vat": 19.97, "step_size": 1 }, // required fields: type, price, step_size (vat optional)
{ "type": "PARKING_TIME", "price": 5.00, "vat": 19.97, "step_size": 60 } // step_size 60 = billed per 60s block
]
// restrictions is optional (day/time/kWh limits) and is not set here
}
],
"start_date_time": "2026-01-01T00:00:00Z", // optional: when this tariff became active
"end_date_time": null, // optional: null = no scheduled expiry
// energy_mix is optional (green-energy disclosure) and is not set here
"last_updated": "2026-01-01T00:00:00Z" // required: when the tariff was last changed
}
],
"charging_periods": [ // required (>=1): the per-period breakdown reconciliation re-computes cost from
{
"start_date_time": "2026-07-03T10:30:00Z", // required: when this period began
"dimensions": [ // required (>=1): each dimension is a type + a volume
{ "type": "ENERGY", "volume": 28.7 }, // required fields: type, volume. 28.7 kWh in this period
{ "type": "TIME", "volume": 0.42 } // 0.42 h of active charging in this period
],
"tariff_id": "TARIFF0001" // optional: which tariff applied to this period
},
{
"start_date_time": "2026-07-03T10:52:23Z", // second period: parking after charging finished
"dimensions": [
{ "type": "PARKING_TIME", "volume": 0.05 } // 0.05 h (3 min) parked before unplugging
],
"tariff_id": "TARIFF0001"
}
],
"signed_data": null, // optional: cryptographically signed meter data (used in regulated EU markets)
"total_cost": { // required: every Price carries excl_vat (required) + incl_vat (optional)
"excl_vat": 11.52,
"incl_vat": 13.82
},
"total_fixed_cost": { "excl_vat": 0.00, "incl_vat": 0.00 }, // optional: fixed session fee component (none here)
"total_energy": 28.7, // required: kWh delivered
"total_energy_cost": { "excl_vat": 10.05, "incl_vat": 12.05 }, // optional: energy portion of the cost
"total_time": 0.42, // required: hours (charging time)
"total_time_cost": { "excl_vat": 1.22, "incl_vat": 1.46 }, // optional: time portion of the cost
"total_parking_time": 0.05, // optional: hours parked after charging finished
"total_parking_cost": { "excl_vat": 0.25, "incl_vat": 0.31 }, // optional: parking portion of the cost
"total_reservation_cost": null, // optional: reservation cost (no reservation here)
"remark": null, // optional: free-text note (used on corrections/adjustments)
"invoice_reference_id": "INV-2026-07-0042", // optional: the CPO's invoice this CDR belongs to
"credit": false, // optional: this is an original CDR, not a correction
"credit_reference_id": null, // optional: set only when credit = true (points at the CDR being corrected)
"home_charging_compensation": false, // optional: true only for home-charging reimbursement CDRs
// RFC 3339, UTC ("Z"). Bump last_updated on every change to the object. A CDR is
// immutable so in practice this equals the creation time, but the field is still required.
"last_updated": "2026-07-03T10:55:30Z" // required
}
# ── eMSP ──▶ CPO (the response) ─────────────────────────────────────────────
# Success is 201 Created (NOT 200), and the eMSP returns a Location response
# header: the URL where this exact CDR can be fetched back. Store that URL,
# because it is how you GET this single CDR later.
HTTP/1.1 201 Created
Content-Type: application/json
Location: https://emsp.example.com/ocpi/emsp/2.2.1/cdrs/CDR0001
X-Request-ID: 12345
X-Correlation-ID: 67890
{
"data": {}, // nothing to echo on a create; the Location header identifies it
// THE key OCPI gotcha: the HTTP 201 only means the call arrived. You must
// ALSO check status_code INSIDE the envelope. 1000 = success. You can get
// HTTP 201 with status_code 2001 (invalid parameters), so always read the
// envelope, never trust the HTTP status alone. 2xxx = client error, 3xxx = server.
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T10:55:31Z"
}
2. The eMSP reads CDRs back (GET from the CPO)
CDRs are a push-first module with a pull safety net. The CPO POSTs each record as it is created, and the eMSP can still pull a time window back to recover anything a failed push dropped. The tradeoffs behind that split are covered in pull vs push patterns (coming soon).
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The eMSP reads from the CPO's Sender Interface. Typical flow: rely on the
# CPO's pushed POSTs for new CDRs, then periodically pull with a date_from window
# as a safety net to catch anything a failed push missed. The list endpoint is
# paginated, so follow the pages or you only ever see the first slice. (A SINGLE
# CDR is fetched instead via the URL from the POST's Location header above.)
GET /ocpi/cpo/2.2.1/cdrs?date_from=2026-07-01T00:00:00Z&offset=0&limit=50 HTTP/1.1
Host: cpo.example.com
# Opposite direction, opposite token: the one the CPO issued to the eMSP.
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
# Query params:
# date_from / date_to → only CDRs changed in this window (delta / catch-up sync)
# offset / limit → paging; the CPO may cap limit, so read the real one back
# from the response headers below.
# ── CPO ──▶ eMSP (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
# Pagination lives in HEADERS, not the body:
# Link → URL of the NEXT page; keep following until there is no "next"
# X-Total-Count → total CDRs matching the filter, across ALL pages
# X-Limit → the page size the CPO actually applied (can be < your limit)
Link: <https://cpo.example.com/ocpi/cpo/2.2.1/cdrs?date_from=2026-07-01T00:00:00Z&offset=50&limit=50>; rel="next"
X-Total-Count: 137
X-Limit: 50
{
"data": [ // an ARRAY of CDR objects (this page only); each element is a FULL CDR
{
"country_code": "NL", // required
"party_id": "TNM", // required
"id": "CDR0001", // required: the same immutable CDR the CPO POSTed above
"start_date_time": "2026-07-03T10:30:00Z", // required
"end_date_time": "2026-07-03T10:55:23Z", // required
"session_id": "SESSION0001", // optional: link back to the Session this bills for
"cdr_token": { // required: snapshot of the token that authorized the charge (all 5 fields required)
"country_code": "NL",
"party_id": "TNM",
"uid": "12345678905880",
"type": "RFID",
"contract_id": "NL-TNM-C12345678-X"
},
"auth_method": "AUTH_REQUEST", // required: AUTH_REQUEST | COMMAND | WHITELIST
"authorization_reference": "AUTHREF0001", // optional
"cdr_location": { // required: SNAPSHOT of the station as it was at charge time
"id": "LOC1", // required
"name": "Amsterdam Central Garage", // optional
"address": "Stationsplein 1", // required
"city": "Amsterdam", // required
"postal_code": "1012 AB", // optional
"state": null, // optional (mostly used in US/CA)
"country": "NLD", // required: alpha-3 here (3 letters)
"coordinates": { "latitude": "52.379189", "longitude": "4.899431" }, // required (both latitude + longitude required)
"evse_uid": "3256", // required
"evse_id": "NL*TNM*E*001", // required
"connector_id": "1", // required
"connector_standard": "IEC_62196_T2", // required
"connector_format": "SOCKET", // required: SOCKET | CABLE
"connector_power_type": "AC_3_PHASE" // required: AC_1_PHASE | AC_2_PHASE | AC_2_PHASE_SPLIT | AC_3_PHASE | DC
},
"meter_id": "METER-AMS-3256-01", // optional
"currency": "EUR", // required
"tariffs": [ // optional: snapshot of the tariff(s) used
{
"country_code": "NL", // required
"party_id": "TNM", // required
"id": "TARIFF0001", // required
"currency": "EUR", // required
"type": "REGULAR", // optional
"tariff_alt_text": [ { "language": "en", "text": "0.35 EUR/kWh, 5 EUR/h parking after charging" } ], // optional
"tariff_alt_url": "https://tnm.example.com/tariffs/TARIFF0001", // optional
"min_price": { "excl_vat": 0.50, "incl_vat": 0.60 }, // optional
"max_price": { "excl_vat": 50.00, "incl_vat": 60.00 }, // optional
"elements": [ // required (>=1)
{
"price_components": [ // required (>=1)
{ "type": "ENERGY", "price": 0.35, "vat": 19.97, "step_size": 1 },
{ "type": "PARKING_TIME", "price": 5.00, "vat": 19.97, "step_size": 60 }
]
}
],
"start_date_time": "2026-01-01T00:00:00Z", // optional
"end_date_time": null, // optional
"last_updated": "2026-01-01T00:00:00Z" // required
}
],
"charging_periods": [ // required (>=1)
{
"start_date_time": "2026-07-03T10:30:00Z",
"dimensions": [
{ "type": "ENERGY", "volume": 28.7 },
{ "type": "TIME", "volume": 0.42 }
],
"tariff_id": "TARIFF0001"
},
{
"start_date_time": "2026-07-03T10:52:23Z",
"dimensions": [ { "type": "PARKING_TIME", "volume": 0.05 } ],
"tariff_id": "TARIFF0001"
}
],
"signed_data": null, // optional
"total_cost": { "excl_vat": 11.52, "incl_vat": 13.82 }, // required
"total_fixed_cost": { "excl_vat": 0.00, "incl_vat": 0.00 }, // optional
"total_energy": 28.7, // required
"total_energy_cost": { "excl_vat": 10.05, "incl_vat": 12.05 }, // optional
"total_time": 0.42, // required
"total_time_cost": { "excl_vat": 1.22, "incl_vat": 1.46 }, // optional
"total_parking_time": 0.05, // optional
"total_parking_cost": { "excl_vat": 0.25, "incl_vat": 0.31 }, // optional
"total_reservation_cost": null, // optional
"remark": null, // optional
"invoice_reference_id": "INV-2026-07-0042", // optional
"credit": false, // optional
"credit_reference_id": null, // optional
"home_charging_compensation": false, // optional
"last_updated": "2026-07-03T10:55:30Z" // required
}
// … up to `limit` CDRs (X-Total-Count 137 across all pages), then follow the Link header for the next page.
],
"status_code": 1000, // again: check this, not just the HTTP 200
"status_message": "Success",
"timestamp": "2026-07-03T11:05:00Z"
}
Why CDRs and Sessions both exist
Why have both, when the Session already carries most of the same data? Three reasons:
1. Immutability. Sessions are mutable and get patched repeatedly while charging is under way. CDRs are write-once and never change. That’s required for accounting and audit trails.
2. Separation of operational vs financial. The Session answers “what is happening right now.” The CDR answers “what do we bill.” Different teams and different systems consume them.
3. Different SLAs. Sessions need to be fast and frequent. CDRs need to be reliable and durable. The two have different reliability/latency tradeoffs.
That split shows up directly in an eMSP app: the Session drives the live screen while the car is plugged in, and the CDR becomes the receipt once it lands.
The reconciliation pattern
Reconciliation on the eMSP side means checking your own records against the CDRs that arrive. The steps worth automating:
- Match each CDR to a Session. Use the
session_idfield. Flag any CDR that doesn’t match a known session. - Validate the math. Recompute the expected cost from the snapshotted Tariff and the charging periods, where each priced dimension supplies the volume its component bills against. Compare to
total_costand flag any divergence. - Spot duplicate CDRs. Two CDRs with the same
session_idare a bug. Flag for manual review. - Spot missing CDRs. Sessions that completed more than X hours ago without a CDR are a problem. Alert the CPO or the operations team.
- Handle credits. Apply credit CDRs to the original’s accounting. Surface the adjustment in customer-facing displays.
Run reconciliation as a batch job on whatever cadence your settlement cycle needs. It is the safety net against silent billing errors, which otherwise surface as customer disputes weeks after the money moved.
Tax handling
CDRs include VAT (or other tax) breakdowns explicitly. The structure:
"total_cost": { "excl_vat": 11.52, "incl_vat": 13.82 }, // example figures only
"total_energy_cost": { "excl_vat": 10.05, "incl_vat": 12.05 },
"total_time_cost": { "excl_vat": 1.22, "incl_vat": 1.46 },
"total_parking_cost": { "excl_vat": 0.25, "incl_vat": 0.31 }
// every other Price-typed field (total_fixed_cost, total_reservation_cost) follows
// the same { excl_vat, incl_vat } shape; incl_vat is optional, excl_vat is required
Each cost field has both excl_vat and incl_vat. The eMSP can present whichever is appropriate for the customer’s jurisdiction.
The rate itself is not a CDR field. It lives in the snapshotted Tariff, which is why a CDR can still be re-priced after the rate changes.
OCPI 2.3.0, published in February 2025, added North American tax level support: multiple stacked taxes (federal, state, county) are represented as separate tax breakdowns rather than a single VAT line. See what changed in OCPI 2.3.0 for the rest of that release.
Signed data and metering evidence
The optional signed_data field carries cryptographically signed metering data. Some jurisdictions require billed energy to be traceable to a legally verified meter, and a signature over the raw readings is how that traceability travels with the record instead of staying inside the charger.
A typical signed_data field contains:
- The meter’s serial number
- Public key
- The actual meter readings
- A digital signature
This lets the eMSP, or a regulator, verify that the kWh on the CDR matches what the calibrated meter recorded. Disputes then get settled on evidence rather than on assertion, and because the signature is self-contained it can be handed to an authority years later.
Whether you need it is a jurisdiction question rather than a preference. German calibration law (Eichrecht) is the canonical driver: billed energy has to be traceable to a verified meter, and the signature is what keeps it traceable after the fact. Where no such rule applies, the field stays null and nothing downstream breaks.
Common implementation gotchas
A few things that catch real-world implementations:
CDRs are immutable: POST, never PUT
The expensive mistake is treating a CDR like an editable record. It is not. A CDR is created once with POST and is never modified, PATCHed, PUT back, or resent. To fix an error you POST a new credit CDR that references the original. You leave the original exactly as it was.
A credit CDR is a normal CDR with two fields set:
POST /ocpi/emsp/2.2.1/cdrs HTTP/1.1
Host: emsp.example.com
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json
{
"country_code": "NL",
"party_id": "TNM",
"id": "CDR0002", // a NEW id: the credit CDR is its own record
"session_id": "SESSION0001", // same session the original billed for
"credit": true, // marks this as a correction
"credit_reference_id": "CDR0001", // the id of the ORIGINAL CDR being corrected
"currency": "EUR",
"total_cost": { // negative to refund an overcharge
"excl_vat": -3.00,
"incl_vat": -3.60
},
"last_updated": "2026-07-04T09:00:00Z"
// … the other required CDR fields (start/end times, cdr_token, cdr_location,
// tariffs, charging_periods, total_energy, total_time) still apply …
}
The original CDR0001 stays untouched; CDR0002 sits alongside it and the net
customer charge is original + credit. Both records survive, so the correction
is always auditable. Point credit_reference_id at the wrong id (or omit it) and
you orphan the correction, so validate on receipt that the referenced CDR exists.
Success is 201 plus a Location header, and you still check the envelope
A successful POST returns HTTP 201 Created, not 200, plus a Location
response header giving the URL to fetch that exact CDR back. Two things
to get right: (1) capture that Location URL, because it is how you GET the single CDR later;
and (2) the HTTP 201 only means the call arrived. You must ALSO read
status_code inside the OCPI envelope. 1000 = success, 2xxx = client error,
3xxx = server error. An HTTP 201 carrying status_code 2001 is a rejection,
not a success. The full code ranges are broken down in
OCPI error responses explained (coming soon).
CDR delivery must be idempotent, because billing depends on it
If the eMSP doesn’t acknowledge (network drop, downtime), the CPO retries the
same POST. The receiver must handle retries idempotently by keying off the CDR
id, so a retried delivery never creates a second billing record. Two CDRs
with the same id (or the same session_id) are a bug; bill the customer twice
and you get a dispute. Reliable, exactly-once handling of CDR receipt is a hard
requirement, not a nice-to-have.
For the authoritative field-by-field definitions, retry semantics, and the full credit-CDR rules, see the OCPI 2.2.1 specification, CDRs module.
Common issues in production
CDRs arrive out of order
Multiple sessions completing in rapid succession can produce CDRs that arrive at the eMSP in a non-chronological order. CDRs are independent of each other, so arrival order does not affect whether any single one is correct. Daily reports should still sort by start_date_time rather than by receipt time.
Tariff snapshots don’t match the live Tariff
If a Tariff was updated between the session and the CDR creation, the snapshotted tariff might be the old version. That is intentional: the CDR captures what applied to that session. But it means reconciliation can’t blindly check against the current live Tariff.
Cost rounding mismatches
total_cost.incl_vat should equal the sum of all _cost subtotals, but rounding can introduce small differences. The mismatch comes from where the rounding happens: rounding each component to cents and then adding them up gives a different answer than computing the total first and rounding once. Tolerances on the eMSP side should be tight but not zero.
Credit CDRs with the wrong reference
A credit CDR with the wrong credit_reference_id orphans the original CDR. Validation on receipt should ensure the referenced CDR exists.
Missing or incomplete charging_periods
A CDR can arrive with a single charging period even when the session crossed a tariff boundary or sat parked after charging finished. The totals may still add up, but the per-period evidence for how they were reached is gone, which makes the record much harder to defend in a dispute. Reconciliation should detect this against the underlying Session.
Why the object is shaped this way
A CDR looks bloated next to a Session, and the redundancy is the entire point. The tariff snapshot, the location snapshot, the token snapshot and the per-period dimensions all exist so the record can be re-priced years later from nothing but the record itself. No lookup against a live Tariff, no query to the CPO’s Locations feed, no dependency on anything that has been edited since. That self-containment is what makes a CDR usable as evidence rather than just a number in a ledger, and it explains why fields that look duplicated are worth the bytes.
There is an asymmetry worth designing around too. An eMSP cannot make a CDR exist. Generation belongs entirely to the CPO, and no OCPI call conjures a missing one into being. The only record on the eMSP side that knows the session happened at all is the eMSP’s own Session inventory, which quietly turns session tracking into a billing control rather than a UX convenience. That is why the “completed session with no matching CDR” check earns its place next to the arithmetic ones: the arithmetic checks catch a CDR that is wrong, and only the inventory check catches a CDR that never arrived.