Bill a session end-to-end
Every completed session produces exactly one Charge Detail Record (CDR): an
immutable billing receipt. This guide picks up where the
quickstart left off, session ses_9nL5tYz2 has already
stopped, and walks through reading its CDR, understanding what priced it,
and reconciling it against your own payment rails.
All requests reuse the Authorization: Bearer $AMPR_API_KEY header from
the quickstart. Every call below is a real request against
https://api.ampr.dev/v1.
Get the finished session
Once a session’s status is completed, its meter readings and duration are
final. The cdr_id field is your pointer to its billing record:
curl https://api.ampr.dev/v1/sessions/ses_9nL5tYz2 \
-H "Authorization: Bearer $AMPR_API_KEY" const res = await fetch('https://api.ampr.dev/v1/sessions/ses_9nL5tYz2', {
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
});
const { data: session } = await res.json();
console.log(session.total_energy_wh, session.cdr_id);
// 8420 "cdr_1hF3mNv8" {
"data": {
"id": "ses_9nL5tYz2",
"charger_id": "chg_2kT8v9xBn4",
"socket_id": "skt_7mR3pQw1",
"token_id": "trk_6pS2qUy4",
"status": "completed",
"start_time": "2026-07-14T12:01:02Z",
"end_time": "2026-07-14T12:41:17Z",
"meter_start": 128400,
"meter_stop": 136820,
"stop_reason": "Remote",
"total_energy_wh": 8420,
"duration_seconds": 2415,
"meter_values": [],
"cdr_id": "cdr_1hF3mNv8"
}
}
total_energy_wh is just meter_stop minus meter_start. It’s informative,
but it isn’t the number you bill from, the CDR’s line_items are, since
those are computed against the tariff that was actually in force.
Fetch its CDR
GET /cdrs/{cdr_id} returns the priced, line-itemized receipt for the
session:
curl https://api.ampr.dev/v1/cdrs/cdr_1hF3mNv8 \
-H "Authorization: Bearer $AMPR_API_KEY" const res = await fetch('https://api.ampr.dev/v1/cdrs/cdr_1hF3mNv8', {
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
});
const { data: cdr } = await res.json();
console.log(cdr.status, cdr.total_cost, cdr.currency);
// "valid" 2.53 "AUD" {
"data": {
"id": "cdr_1hF3mNv8",
"session_id": "ses_9nL5tYz2",
"tariff_id": "trf_2bD6fMy3",
"currency": "AUD",
"start_time": "2026-07-14T12:01:02Z",
"end_time": "2026-07-14T12:41:17Z",
"total_energy_wh": 8420,
"total_time_s": 2415,
"total_cost": 2.53,
"line_items": [
{
"type": "ENERGY",
"description": "Energy delivered",
"quantity": 8.42,
"unit_price": 0.30,
"cost": 2.53
}
],
"status": "valid",
"payment_references": [],
"billing_decision": null,
"created_at": "2026-07-14T12:41:20Z"
}
}
status: "valid" means the CDR priced cleanly against a resolved tariff.
The other possible value, unbillable, means the session completed but no
tariff could be resolved, the CDR still exists as a record but total_cost
is zero. billing_decision is null here because nothing needed explaining:
the meter data was complete and the tariff resolved normally. You’ll see a
populated one further down, for sessions where that isn’t true.
payment_references is empty until you or your own payment system
reconciles a PSP transaction against this CDR, that’s the last step below.
See what priced it
tariff_id on the CDR points at the tariff that was resolved for this
session at start time. Fetch it directly, or list every tariff on your
account with GET /tariffs:
curl https://api.ampr.dev/v1/tariffs/trf_2bD6fMy3 \
-H "Authorization: Bearer $AMPR_API_KEY" const res = await fetch('https://api.ampr.dev/v1/tariffs/trf_2bD6fMy3', {
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
});
const { data: tariff } = await res.json();
console.log(tariff.elements[0].price_components);
// [{ type: "ENERGY", price: 0.3, step_size: 1000 }] {
"data": {
"id": "trf_2bD6fMy3",
"name": "Standard Rate",
"currency": "AUD",
"elements": [
{
"price_components": [
{ "type": "ENERGY", "price": 0.30, "step_size": 1000 }
]
}
],
"created_at": "2026-06-01T09:00:00Z",
"updated_at": "2026-06-01T09:00:00Z"
}
}
A tariff is one or more elements, each with its own price_components
(ENERGY, TIME, PARKING_TIME, or FLAT) and optional time-of-day or
day-of-week restrictions. step_size is the minimum billable unit, 1000
here means Ampr bills per kWh on a Wh meter. Tariffs are managed with
POST /tariffs and PATCH /tariffs/{tariff_id}; changing one only affects
sessions that start after the change, past CDRs keep whatever priced them.
When the meter data isn’t clean
Not every session ends with a tidy StopTransaction. A charger can drop
offline mid-charge, or never report a stop at all. Ampr still finalizes and
bills those sessions, and it records exactly how and why with a
billing_decision: GET /cdrs/{cdr_id}/billing-decision is the audit
record.
Here’s that endpoint for a different CDR, one from a session the charger never cleanly stopped:
curl https://api.ampr.dev/v1/cdrs/cdr_8mQ4wRx1/billing-decision \
-H "Authorization: Bearer $AMPR_API_KEY" const res = await fetch(
'https://api.ampr.dev/v1/cdrs/cdr_8mQ4wRx1/billing-decision',
{
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
}
);
const { data } = await res.json();
console.log(data.decision.outcome, data.decision.reason_code);
// "recovered_partial" "missing_stop_recovered" {
"data": {
"cdr_id": "cdr_8mQ4wRx1",
"session_id": "ses_3xTfLq95",
"decision": {
"outcome": "recovered_partial",
"reason_code": "missing_stop_recovered",
"explanation": "No StopTransaction was received. Billed to the last confirmed meter reading, received 41 minutes before the session was force-finalized.",
"evidence": {
"regime": "confirmed_register_ok",
"lastSufficientRegister": {
"valueWh": 41200,
"timestamp": "2026-07-13T09:22:11Z",
"sourceMessageId": "msg_9kLp2Rn4"
},
"missing": ["stop_frame"],
"meterStartWh": 12800,
"frameRef": {
"chargerId": "chg_2kT8v9xBn4",
"ocppVersion": "1.6",
"nativeTransactionId": "184773"
}
}
},
"chain": [
{
"outcome": "recovered_partial",
"reason_code": "missing_stop_recovered",
"explanation": "No StopTransaction was received. Billed to the last confirmed meter reading, received 41 minutes before the session was force-finalized.",
"evidence": {
"regime": "confirmed_register_ok",
"lastSufficientRegister": {
"valueWh": 41200,
"timestamp": "2026-07-13T09:22:11Z",
"sourceMessageId": "msg_9kLp2Rn4"
},
"missing": ["stop_frame"],
"meterStartWh": 12800,
"frameRef": {
"chargerId": "chg_2kT8v9xBn4",
"ocppVersion": "1.6",
"nativeTransactionId": "184773"
}
},
"supersedes": null,
"created_at": "2026-07-13T10:05:00Z"
}
]
}
}
outcome is one of billed (normal, complete data), recovered_partial
(billed to the last known-good register after missing data), or
unbillable (not enough evidence to bill at all). evidence carries a
lastSufficientRegister (the last register Ampr trusted, with the OCPP
message id it came from) and a frameRef identifying the charger and
transaction, so the number isn’t a guess, it traces to a specific frame.
evidence is an opaque audit object (stored as jsonb, with
additionalProperties: true in the spec), not a field-by-field typed
contract. Its keys happen to be camelCase even though the rest of the API
is snake_case, that’s a known quirk of it being passed through verbatim
from storage. Read it for context; don’t build strict parsing against it
beyond regime, missing, and frameRef, which are stable in practice.
Corrections never rewrite history
CDRs are immutable. Ampr never edits or deletes one, and it never edits a
billing_decision either, once written, both stay exactly as they were.
So what happens when better data shows up later, say the charger’s missed
StopTransaction finally arrives out of band, and the recovered reading
turns out to have undercounted the session? Ampr doesn’t touch the original
CDR or its decision. It issues a new CDR that reverses the original (a
credit, tied to the same session) and records a new billing_decision on
it with supersedes set to the id of the decision it corrects. Querying
the original CDR’s billing decision afterward returns the same, unchanged
decision, but its chain now shows the correction too, newest-first:
{
"data": {
"cdr_id": "cdr_8mQ4wRx1",
"session_id": "ses_3xTfLq95",
"decision": {
"outcome": "recovered_partial",
"reason_code": "missing_stop_recovered",
"explanation": "No StopTransaction was received. Billed to the last confirmed meter reading, received 41 minutes before the session was force-finalized.",
"evidence": { "...": "unchanged" }
},
"chain": [
{
"outcome": "billed",
"reason_code": "late_stop_reconciled",
"explanation": "The missing StopTransaction was recovered from the gateway's frame log. Rebilled to the confirmed final register.",
"evidence": {
"regime": "confirmed_register_ok",
"lastSufficientRegister": {
"valueWh": 41870,
"timestamp": "2026-07-13T09:22:11Z",
"sourceMessageId": "msg_9kLp3Fq7"
},
"missing": [],
"meterStartWh": 12800,
"frameRef": {
"chargerId": "chg_2kT8v9xBn4",
"ocppVersion": "1.6",
"nativeTransactionId": "184773"
}
},
"supersedes": "bdec_4hK2wLp9",
"created_at": "2026-07-14T08:00:00Z"
},
{
"outcome": "recovered_partial",
"reason_code": "missing_stop_recovered",
"explanation": "No StopTransaction was received. Billed to the last confirmed meter reading, received 41 minutes before the session was force-finalized.",
"evidence": {
"regime": "confirmed_register_ok",
"lastSufficientRegister": {
"valueWh": 41200,
"timestamp": "2026-07-13T09:22:11Z",
"sourceMessageId": "msg_9kLp2Rn4"
},
"missing": ["stop_frame"],
"meterStartWh": 12800,
"frameRef": {
"chargerId": "chg_2kT8v9xBn4",
"ocppVersion": "1.6",
"nativeTransactionId": "184773"
}
},
"supersedes": null,
"created_at": "2026-07-13T10:05:00Z"
}
]
}
}
decision is the current decision for the id you asked about, it never
moves. chain is the full, append-only history for the session behind it.
That’s the shape to build reconciliation against: never overwrite a CDR you
already booked, always check whether a newer entry has landed in the chain.
The bdec_... value in supersedes is illustrative of the internal
billing-decision id that row corrects. The public API doesn’t expose a
billing_decision.id field anywhere else, supersedes is the only place
you’ll see one, and only as a reference, not something you can fetch
directly.
Treat every CDR you’ve stored as append-only on your side too. A correction shows up as a new CDR and a new chain entry, never as a change to a record you already have.
Record your own payment reference
Once you’ve settled a CDR against your own payment rails (your CPO’s Stripe account, a terminal, an invoice), attach that reference so it’s reconciled on the Ampr side:
curl -X POST https://api.ampr.dev/v1/cdrs/cdr_1hF3mNv8/payment-reference \
-H "Authorization: Bearer $AMPR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payment_reference": "pi_3QabcXYZ",
"payment_provider": "stripe"
}' const res = await fetch(
'https://api.ampr.dev/v1/cdrs/cdr_1hF3mNv8/payment-reference',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payment_reference: 'pi_3QabcXYZ',
payment_provider: 'stripe',
}),
}
);
const { data: reference } = await res.json();
console.log(reference.id, reference.cdr_id);
// "pyr_1hF3mNv8" "cdr_1hF3mNv8" {
"data": {
"id": "pyr_1hF3mNv8",
"session_id": "ses_9nL5tYz2",
"cdr_id": "cdr_1hF3mNv8",
"payment_reference": "pi_3QabcXYZ",
"payment_provider": "stripe",
"created_at": "2026-07-14T12:45:00Z"
}
}
This requires the operator role or higher, and it’s idempotent per
(session, reference), calling it twice with the same pair returns 200
instead of creating a duplicate. payment_reference must be your own PSP’s
transaction id, never a driver identifier or OCPP idTag. The equivalent
POST /sessions/{session_id}/payment-reference works even before the CDR
exists, the reference links up automatically once it’s generated. The
immutable CDR row itself is never touched either way, the reference lives
in a separate annotation joined onto payment_references.
Don’t want to poll for a finished CDR? The handle webhooks
guide covers the cdr.finalized event,
fired the moment a CDR is ready to reconcile. For the bigger picture on
how Ampr fits into your payment flow, see billing on
Ampr.