ampr docs

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.

Note

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:

Get the session
curl https://api.ampr.dev/v1/sessions/ses_9nL5tYz2 \
-H "Authorization: Bearer $AMPR_API_KEY"
{
  "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:

Get the CDR
curl https://api.ampr.dev/v1/cdrs/cdr_1hF3mNv8 \
-H "Authorization: Bearer $AMPR_API_KEY"
{
  "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:

Get the tariff
curl https://api.ampr.dev/v1/tariffs/trf_2bD6fMy3 \
-H "Authorization: Bearer $AMPR_API_KEY"
{
  "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:

Get the billing decision
curl https://api.ampr.dev/v1/cdrs/cdr_8mQ4wRx1/billing-decision \
-H "Authorization: Bearer $AMPR_API_KEY"
{
  "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.

Note

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.

Warning

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:

Attach a payment reference
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"
}'
{
  "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.

Tip

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.