Handle webhooks reliably
Polling for state changes wastes calls and adds latency. Webhooks push a session’s meter updates, a charger’s fault, or a finished CDR to your own server the moment Ampr knows about them, this is what the bill a session and connect an OCPP charger guides point to instead of polling. This guide covers the part that’s easy to get wrong: verifying that a request actually came from Ampr, and handling the redelivery that a distributed system guarantees will eventually happen.
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.
Register a webhook endpoint
POST /webhooks takes a url and a list of events to subscribe to. It
requires the admin role. The url must be HTTPS, Ampr only relaxes that
for localhost while you’re developing against it locally:
curl -X POST https://api.ampr.dev/v1/webhooks \
-H "Authorization: Bearer $AMPR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/ampr",
"events": ["session.ended", "cdr.finalized", "charger.faulted"]
}' const res = await fetch('https://api.ampr.dev/v1/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AMPR_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/webhooks/ampr',
events: ['session.ended', 'cdr.finalized', 'charger.faulted'],
}),
});
const { data: webhook } = await res.json();
console.log(webhook.id, webhook.secret);
// "whk_6pS2qUy4" "3f9a1c... (64 hex chars)" {
"data": {
"id": "whk_6pS2qUy4",
"url": "https://example.com/webhooks/ampr",
"events": ["session.ended", "cdr.finalized", "charger.faulted"],
"secret": "3f9a1c7b2e6d4508f9a3c1b6e2d7f4508f9a3c1b6e2d7f4508f9a3c1b6e2d745",
"enabled": true,
"created_at": "2026-07-14T12:00:00Z"
}
}
secret is only ever returned in this response. GET/PATCH on the same
webhook always return secret: null. Store it now, in whatever secret
manager you use for AMPR_API_KEY, there’s no endpoint to retrieve it
again. If you lose it, delete the webhook and register a new one.
An unknown value in events is rejected with a 422 rather than silently
accepted, so you can’t subscribe to a typo’d event name and never notice it
never fires. PATCH /webhooks/{webhook_id} updates url, events, or
enabled on an existing webhook; DELETE /webhooks/{webhook_id} removes
it.
Event types and the payload envelope
Every delivery, regardless of event, is wrapped in the same envelope:
{
"id": "b3f9a2d4-6e51-4c8a-9d02-7f1e3a9c5b6e",
"type": "cdr.finalized",
"created_at": "2026-07-14T12:41:22Z",
"data": {
"cdr_id": "cdr_1hF3mNv8",
"session_id": "ses_9nL5tYz2",
"outcome": "billed",
"reason_code": "complete",
"status": "valid"
}
}
id is a delivery-specific UUID, type is one of the 15 event names below,
and data is a small, event-specific object, not a full copy of the
resource. If you need the full charger, session, or CDR, data gives you
the id to GET it.
The full set of event types you can subscribe to:
charger.connected,charger.disconnected,charger.online,charger.offline,charger.faulted,charger.firmware_statussocket.status_changedsession.started,session.meter_update,session.endedcdr.created,cdr.finalizedcommand.completed,command.failedauthorize.unknown_token_admitted
cdr.created fires as soon as the CDR row exists, often before any payment
reference is attached. cdr.finalized is the one the bill a
session guide points to, it’s the signal
that a CDR’s outcome (billed, recovered_partial, or unbillable) and
status are settled and it’s safe to reconcile.
Verify the signature
Every delivery carries three headers:
| Header | Value |
|---|---|
X-Ampr-Signature | sha256=<hex HMAC-SHA256 of the raw request body> |
X-Ampr-Event | the event type, e.g. cdr.finalized |
X-Ampr-Delivery | a stable id for this delivery, unchanged across retries |
The signature is an HMAC-SHA256, keyed with your webhook’s secret, computed
over the exact bytes of the request body, not a re-serialization of the
parsed JSON. Read the raw body before you parse it, key ordering or
whitespace differences between the original body and anything you
re-stringify will produce a signature that doesn’t match:
# Recompute the signature locally against a captured delivery, useful when
# a handler is unexpectedly rejecting valid requests. $BODY is the exact raw
# body you received, byte for byte.
echo -n "$BODY" | openssl dgst -sha256 -hmac "$AMPR_WEBHOOK_SECRET"
# (stdin)= 7c2f9e1b...
# Compare this hex digest to the part after "sha256=" in the
# X-Ampr-Signature header on the same request. import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyAmprSignature(rawBody: string, header: string | null, secret: string): boolean {
if (!header?.startsWith('sha256=')) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const providedBuf = Buffer.from(header.slice('sha256='.length), 'hex');
if (expectedBuf.length !== providedBuf.length) return false;
return timingSafeEqual(expectedBuf, providedBuf);
}
// Read the raw text body BEFORE calling .json() on it, re-serializing a
// parsed object will not reproduce the exact bytes Ampr signed.
const rawBody = await request.text();
const signatureHeader = request.headers.get('x-ampr-signature');
if (!verifyAmprSignature(rawBody, signatureHeader, process.env.AMPR_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody); timingSafeEqual matters here as much as the HMAC itself, comparing digests
with === leaks timing information an attacker can use to forge a valid
signature one byte at a time. Reject anything that doesn’t compare equal,
and reject a missing or malformed header the same way you’d reject a bad
signature, don’t treat “no header” as “trust it anyway.”
Idempotency and retries
Delivery is at-least-once, the same guarantee the rest of Ampr’s
pipeline runs on end to end (gateway to SQS to consumer). A delivery can
arrive twice: your endpoint might process an event successfully and then
crash before returning 200, or a retry can race a slow first attempt. Your
handler has to tolerate that.
If your endpoint doesn’t return a 2xx, Ampr retries the same delivery on a
fixed backoff schedule, 5 attempts total over about 5.5 minutes: the first
retry after 1 second, then 5 seconds, then 30 seconds, then 5 minutes.
X-Ampr-Delivery stays the same across every one of those attempts, use it
as your idempotency key. If the endpoint still hasn’t returned a 2xx after
that 5th attempt, Ampr gives up, and if 5 separate
deliveries each exhaust their retries, the webhook itself is disabled
(enabled: false) until you re-enable it with PATCH. Any successful
delivery resets that counter, an endpoint that’s occasionally flaky doesn’t
get permanently disabled over one bad hour.
# Resend the exact same delivery twice (same headers, same body) to confirm
# your handler is safe under redelivery: it should return 200 both times
# without double-processing the event.
curl -X POST https://your-app.example.com/webhooks/ampr \
-H "Content-Type: application/json" \
-H "X-Ampr-Signature: sha256=$SIGNATURE" \
-H "X-Ampr-Event: cdr.finalized" \
-H "X-Ampr-Delivery: dlv_9kQpL3xR" \
-d "$BODY" async function handleAmprWebhook(request: Request): Promise<Response> {
const rawBody = await request.text();
const deliveryId = request.headers.get('x-ampr-delivery');
const signatureHeader = request.headers.get('x-ampr-signature');
if (!deliveryId || !verifyAmprSignature(rawBody, signatureHeader, process.env.AMPR_WEBHOOK_SECRET!)) {
return new Response('invalid signature', { status: 401 });
}
// Retries reuse the same X-Ampr-Delivery id. Dedupe on it before doing any
// side-effecting work, a unique constraint on a "processed_deliveries"
// table works as well as a Redis SETNX; what matters is that the
// check-and-mark is atomic, not the storage.
const isNew = await markDeliveryProcessed(deliveryId);
if (!isNew) {
return new Response(null, { status: 200 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'cdr.finalized':
await handleCdrFinalized(event.data);
break;
case 'session.ended':
await handleSessionEnded(event.data);
break;
// ...handle the other subscribed event types
}
return new Response(null, { status: 200 });
} Never process a webhook payload before its signature verifies, and never assume a delivery is unique. At-least-once means your handler is the last line of defense against double-billing a customer or double-firing a side effect off the same event.
Return 2xx as soon as you’ve durably queued the event, and do the actual
work (updating your own DB, calling out to a third party) after responding
if it’s slow. A handler that blocks on slow downstream work risks tripping
the delivery timeout and triggering a retry for an event you already
received correctly.