IDs, errors, and versioning
Every other guide shows you the happy path: register a charger, start a session, get paid. This one covers the parts that don’t change from call to call, the shape of an id, the shape of an error, and what “stable” actually means for this API. If you’re writing an integration that’s meant to outlive your first afternoon with it, this is what to build against.
Resource IDs
Every id Ampr returns is a resource-type prefix, an underscore, then an opaque, URL-safe string. The prefix is part of the API contract, it never changes for a given resource, so you can use it to route or validate without a lookup:
| Resource | Prefix | Example |
|---|---|---|
| Charger | chg_ | chg_2kT8v9xBn4 |
| Socket | skt_ | skt_7mR3pQw1 |
| Session | ses_ | ses_9nL5tYz2 |
| Command | cmd_ | cmd_4jK8rWx6 |
| CDR (charge detail record) | cdr_ | cdr_1hF3mNv8 |
| Payment reference | pyr_ | pyr_1hF3mNv8 |
| Location | loc_ | loc_5cA4hLx7 |
| Tariff | trf_ | trf_2bD6fMy3 |
| Token (RFID/app credential) | trk_ | trk_6pS2qUy4 |
| Webhook | whk_ | whk_6pS2qUy4 |
| Organization | org_ | org_7cX2wQa9 |
| API key | key_ | key_...b9x4 (masked; the full value is only ever shown once) |
| User (dashboard account) | usr_ | usr_2kT8v9xBn4 |
curl https://api.ampr.dev/v1/chargers/chg_2kT8v9xBn4 \
-H "Authorization: Bearer $AMPR_API_KEY" const res = await fetch('https://api.ampr.dev/v1/chargers/chg_2kT8v9xBn4', {
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
});
const { data: charger } = await res.json();
console.log(charger.id, charger.status);
// "chg_2kT8v9xBn4" "online" Every id in that table is what its own endpoint actually returns, not a
display convention layered on top of something else. A session’s
token_id is a real trk_ id, not the raw RFID/app credential the charger
presented as an OCPP idTag. That raw value lives only on the tokens
resource (Token.uid) and is never copied onto a session, if you used to
read the old id_tag field directly, token_id is what replaced it.
Treat every id as an opaque string once you have it. The prefix is a stable signal you can branch on, the characters after it aren’t. Don’t decode them, don’t assume a fixed length, don’t sort by them for anything other than a display order you control yourself. Hyrum’s Law cuts both ways here: if enough integrations start depending on some incidental property of today’s ids (a length, a charset, an ordering), that incidental property becomes a contract we have to keep forever. We’d rather keep the actual contract narrow, the prefix is guaranteed, nothing else about the string is.
Errors
Every error response, from a malformed request to a database timeout, comes
back as the same shape: RFC 7807
Problem Details, served with Content-Type: application/problem+json.
| Field | Type | Notes |
|---|---|---|
type | string (URI) | A stable identifier for the error category, e.g. https://api.ampr.dev/errors/not-found. Safe to match on in code. |
title | string | Short, human-readable summary. Wording can change without it being a breaking change. |
status | integer | The same value as the HTTP status code. |
detail | string or null | Human-readable specifics for this particular occurrence. |
instance | string or null | The request path that produced the error. |
A request for a charger that doesn’t exist in your organization:
{
"type": "https://api.ampr.dev/errors/not-found",
"title": "Charger not found",
"status": 404,
"detail": "No charger with ID chg_bogus000 exists in your organization.",
"instance": "/v1/chargers/chg_bogus000"
}
curl -i https://api.ampr.dev/v1/chargers/chg_bogus000 \
-H "Authorization: Bearer $AMPR_API_KEY"
# HTTP/1.1 404 Not Found
# Content-Type: application/problem+json const res = await fetch('https://api.ampr.dev/v1/chargers/chg_bogus000', {
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
},
});
if (!res.ok) {
const problem = await res.json();
// problem.type is the field to branch on, never problem.title, titles
// are for humans and can be reworded without notice.
if (problem.type === 'https://api.ampr.dev/errors/not-found') {
console.error(`charger not found: ${problem.detail}`);
} else {
console.error(`unexpected error (${problem.status}): ${problem.detail}`);
}
throw new Error(problem.title);
}
const { data: charger } = await res.json(); Some validation failures carry one more field. POST /webhooks with no
url and no events returns every field-level problem at once instead of
making you fix and resubmit one at a time:
{
"type": "https://api.ampr.dev/errors/validation",
"title": "Validation failed",
"status": 400,
"detail": "url: is required; events: at least one event type is required",
"instance": "/v1/webhooks",
"errors": [
{ "field": "url", "message": "is required" },
{ "field": "events", "message": "at least one event type is required" }
]
}
The spec’s base error schema only requires type, title, and status,
detail and instance are optional, and errors is additional detail
included on field-level validation failures like this one.
The handle webhooks guide covers the
related case, subscribing to an event type outside the documented set
(say, charger.exploded) doesn’t fail the same way. It’s a well-formed
request the API understands but refuses to act on, so it comes back as a
422 with a plain detail string instead of an errors array:
{
"type": "https://api.ampr.dev/errors/unprocessable",
"title": "Unprocessable entity",
"status": 422,
"detail": "Unknown event type(s): charger.exploded. Valid types: charger.connected, charger.disconnected, ...",
"instance": "/v1/webhooks"
}
400 means the shape of the request is wrong, 422 means the shape is
fine but the content can’t be carried out. Match on type, not status,
if you need to tell cases like these apart programmatically, the status
code alone collapses distinctions you might want to handle differently.
Versioning & stability
The version lives in the URL, not a header: every call in this API goes
through https://api.ampr.dev/v1. There’s one version today. If a change
ever breaks a documented response shape, it moves to the next path segment
rather than mutating /v1 underneath you, so code written against /v1
keeps working for as long as /v1 is served.
That’s the same posture the API takes toward its own database. Migrations
ship additive-only in the same deploy as the code that needs them, and a
destructive change (dropping a column, tightening a type) splits across an
expand step and a later contract step, so the schema and the running code
are never more than one deploy apart. The public surface gets the same
treatment: a session’s token_id field replacing the old raw id_tag
field is the one breaking change of that shape so far, the new field
shipped and consumers moved onto it before the old one was ever retired.
Three things are frozen contracts, held to that same standard:
- The OpenAPI spec. Request and response shapes, required fields, and enum values don’t shift out from under a version. New optional fields can be added; existing ones aren’t repurposed or removed without a version change.
- Webhook payloads. The envelope (
id,type,created_at,data) and thedatashape for each event type in the handle webhooks guide are as stable as any REST response, a delivery is a request you didn’t initiate, but it’s still/v1. - Id formats. The prefixes in the table above are permanent. New resources get new prefixes; existing ones never change shape.
Nothing outside the documented response shape is part of the contract, field ordering in the JSON, undocumented properties that happen to be present today, or timing between webhook deliveries beyond what handle webhooks specifies. If your integration depends on something this guide doesn’t describe, treat that as a bug in your integration, not a feature you can rely on staying put.