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.
This article walks through what’s in a CDR, why immutability matters, and the operational patterns around CDR exchange.
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) - Tariffs used are snapshotted (so the CDR doesn’t depend on the live Tariff which might have changed since)
- The Location data is snapshotted too
- The CDR ID is different from the Session ID — there’s a
session_idlink
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 (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. This preserves the audit trail — both the original (with the error) and the correction are 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 (all 30 fields) — 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.
If the eMSP doesn’t acknowledge (network issue, downtime), the CPO retries. CDR delivery must be reliable — 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 and the eMSP reading CDRs back — with the wire-level details
that trip people up 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 — 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": {}, // receivers usually echo nothing on a create
// 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) — 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)
# ── 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 — 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 — 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
It’s reasonable to ask: why have both? The Session already has all the same data. Why publish a separate CDR?
Three reasons:
1. Immutability. Sessions are mutable — they get patched many times during the session. CDRs are write-once and never change. That’s required for accounting and audit trails.
2. Separation of operational vs financial. The Session is operational — “what’s happening right now.” The CDR is financial — “here’s what to bill.” Different teams and 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.
In practice, an eMSP’s app uses the Session for live UX and switches to the CDR (when it arrives) for the final receipt.
The reconciliation pattern
eMSPs reconcile their own records against the CDRs they receive. Standard reconciliation steps:
- Match each CDR to a Session. Use the
session_idfield. Flag any CDR that doesn’t match a known session. - Validate the math. Compute the expected cost from the snapshotted Tariff + charging periods. Compare to
total_cost. 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.
Reconciliation often runs daily or hourly as a batch job. It’s the safety net against silent billing errors.
Tax handling
CDRs include VAT (or other tax) breakdowns explicitly. The structure:
"total_cost": { "excl_vat": 11.52, "incl_vat": 13.82 }, // 19.97% VAT in the Netherlands
"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.
In OCPI 2.3.0, North American tax level support was added — multiple stacked taxes (federal, state, county) get represented as separate tax breakdowns rather than a single VAT line.
Signed data — for compliance and disputes
The optional signed_data field carries cryptographically signed metering data. This is required in some EU jurisdictions for compliance with calibration regulations.
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 actually recorded. It prevents disputes — and the data is portable, so it can be presented to authorities.
Most regional implementations don’t use signed_data, but EU implementations in regulated markets (Germany in particular) do.
Common implementation gotchas
A few things that catch real-world implementations:
CDRs are immutable — POST, never PUT — the #1 mistake
The single most common 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 — validate on receipt that the referenced CDR exists.
Success is 201 + 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 people
miss: (1) capture that Location URL — 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 with status_code 2001 is a rejection, not a
success.
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 — keying off the CDR
id — so a retried delivery doesn’t create a duplicate 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 — order doesn’t matter for individual correctness, but daily reports should be sortable by start_date_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. This 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. Some implementations are lax about this and create off-by-a-cent mismatches. Tolerances at 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
Some implementations send CDRs with empty or single-period charging_periods even for sessions that should have multiple periods. This loses billing detail. Reconciliation should detect this against the underlying Session.
Three things to take from this
-
CDRs are write-once and immutable. Corrections happen via credit CDRs, never by modifying the original.
-
CDRs ≠ Sessions. Sessions are operational/live; CDRs are financial/final. Both serve different consumers.
-
Reconciliation is essential. Don’t trust CDRs blindly. Validate against Sessions, validate the math, and flag anomalies. Billing errors compound silently if reconciliation isn’t running.
CDRs are the foundation of inter-network commerce in EV charging. Every dollar that flows between CPOs and eMSPs starts with a CDR. Getting the implementation right is the difference between a billing system that works and one that produces customer disputes daily.