ampr docs

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.

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.

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:

Register a webhook
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"]
}'
{
  "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"
  }
}
Warning

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_status
  • socket.status_changed
  • session.started, session.meter_update, session.ended
  • cdr.created, cdr.finalized
  • command.completed, command.failed
  • authorize.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:

HeaderValue
X-Ampr-Signaturesha256=<hex HMAC-SHA256 of the raw request body>
X-Ampr-Eventthe event type, e.g. cdr.finalized
X-Ampr-Deliverya 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:

Verify the HMAC
# 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.

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.

Idempotent handler
# 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"
Warning

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.