Webhooks
What webhooks are for
Webhooks push notifications to an HTTPS endpoint you control whenever something happens in your integration that you care about. Each notification is delivered as a signed HTTP POST containing a JSON payload. Webhooks let you react in near real time without polling the REST API.
This page describes the parts of webhook delivery that are the same for every event: the envelope shape, the signature scheme, retries, idempotency, and ordering. The per-event payload (what data actually contains) is documented on the sub-page for each event type, and machine-readable in the Webhooks reference.
Available events
| Event type | Description |
|---|---|
session.updated | Fired whenever a charging session changes state or its energy/cost figures change. |
More event types will be introduced over time. Each one will be documented on its own sub-page.
Getting set up
Webhook configuration is not self-serve. To start receiving events, contact us with:
- The HTTPS endpoint that should receive deliveries.
- The event types you want to subscribe to.
- A short description (optional) so we can label the webhook in our system.
We will:
- Create the webhook against your integration.
- Generate a signing secret of the form
whsec_...and share it with you over a secure channel. - Send a synthetic test event to your endpoint so you can verify the receiver before any real traffic flows. The synthetic event goes through the exact same delivery pipeline, signature, and retry policy as a production event.
Keep the secret somewhere your application can read it (a secrets manager, an env var). Anyone with the secret can forge a delivery, so treat it like an API key.
The delivery envelope
ChargeNow delivers a CloudEvents 1.0 envelope as the HTTP body. The shape is the same for every event; only data differs, carrying the event-specific payload. Field by field, the envelope and each payload are in the Webhooks reference.
{
"specversion": "1.0",
"id": "5b9d6c1a-3d2f-5e88-9c1f-1a5b2e0e8e1a",
"type": "session.updated",
"source": "charge-now/api",
"time": "2026-06-15T10:14:22.482931Z",
"datacontenttype": "application/json",
"data": { ...event-specific fields... }
}
Two fields matter for delivery handling: deduplicate on the top-level id (stable across retries, and the only id the signature covers), and order events for the same resource by time (a later time wins). New optional keys may appear over time; ignore unknown ones.
Headers
Content-Type: application/json
Webhook-Id: <unique id per (webhook, event); stable across retries>
Webhook-Event: <event type, e.g. session.updated>
Webhook-Timestamp: 1781234567
Webhook-Signature: 5f3b9c... (hex-encoded HMAC-SHA256)
The reference documents each header. Two matter here: Webhook-Timestamp and Webhook-Signature drive signature verification (below). Webhook-Id is handy for support requests but is not signed, so deduplicate on the envelope id, not on it.
Verifying the signature
Always verify the signature before trusting any field in the body. The verification recipe is:
- Read the raw request body as bytes. Do not parse and re-serialise it first, JSON formatting differences will break the signature.
- Read the
Webhook-Timestampheader. - Compute
HMAC-SHA256(secret, "<timestamp>.<raw-body>")and hex-encode it. - Compare with the
Webhook-Signatureheader using a constant-time comparison. - Reject the request if the comparison fails, or if the timestamp is older than a sensible window (we recommend 5 minutes).
Any language with a standard crypto library exposes the primitives you need: an HMAC-SHA256 implementation (e.g. crypto/hmac in Go, crypto in Node.js, hmac in Python, javax.crypto.Mac in Java) and a constant-time byte comparison (e.g. hmac.Equal, crypto.timingSafeEqual, hmac.compare_digest). Do not use a plain == on the digest, a naive comparison can leak the secret over time through timing side channels.
Two implementation details worth highlighting:
- Capture the raw request body before any middleware parses it. Most web frameworks expose a way to read the body as bytes (Express's
express.raw, Go'sio.ReadAll(r.Body), Flask'srequest.get_data(), etc.). Parse the JSON only after the signature has verified. - Build the signing input as
"<timestamp>.<raw-body>"with a single ASCII.between the two parts. Feed it into HMAC-SHA256 keyed by your secret, hex-encode the digest, then compare.
How to respond
| Your response | What ChargeNow does |
|---|---|
2xx | Treats the delivery as successful and does not retry. |
408 Request Timeout, 425 Too Early, 429 Too Many Requests | Retries with exponential backoff. |
Any other 4xx | Stops retrying. The delivery is considered permanently failed. Use this for "I will never accept this" cases (e.g. signature mismatch is your bug, not ours). |
5xx, connection error, TLS error, read timeout | Retries with exponential backoff. |
Respond quickly. The HTTP call has a 30 second timeout per attempt, after which we treat the attempt as failed and retry. Acknowledge the delivery first and process the event asynchronously (queue, background job) if your handler takes longer.
Retries
Failed attempts are retried with exponential backoff and jitter, capped at 30 minutes between attempts, up to 25 attempts spread over roughly 9.5 hours. The first few intervals are approximately:
5s, 15s, 45s, 2m15s, 6m45s, 20m15s, then 30m for the remaining attempts
After 25 attempts the delivery is dropped. There is no automated replay. If your endpoint was down for longer than the retry window, reconcile via the REST API.
Idempotency and duplicates
ChargeNow guarantees at-least-once delivery. The same event may arrive more than once for legitimate reasons (a retry that completed on our side after a network blip, an upstream reprocess). Build your receiver to be idempotent.
The recommended strategy:
- Use the envelope
id(top-level CloudEventsid, not theWebhook-Idheader) as your deduplication key. - The
idis stable across all retries of the same logical event and is the only id protected by the signature. - Track processed ids for at least the length of the retry window (10 hours is a safe minimum, 24 hours is comfortable).
Ordering and race conditions
Webhooks are not strictly ordered. Two events for the same resource can arrive out of order, particularly if the first one had to be retried while the second went through on the first attempt. This is normal industry behaviour and not specific to ChargeNow.
Practical rules:
- Treat each event as a snapshot, not a delta. The
datapayload reflects the resource's known state at the time the event was produced. - Use the envelope
timefield to detect stale events. If you already processed an event with a latertimefor the same resource, skip the older one. - Do not assume events arrive in the order they happened. A later
timealways wins.
A robust handler in pseudocode:
on event:
if seen(event.id): return 200
record = load(resource_id from event.data)
if record.last_event_time and record.last_event_time >= event.time:
mark_seen(event.id)
return 200
apply(event.data)
record.last_event_time = event.time
mark_seen(event.id)
return 200
Per-event-type ordering nuances (e.g. settlement corrections for session.updated) are documented on each event's sub-page.
Reconciliation
Webhooks are an optimisation over polling, not a replacement for the source of truth. If correctness matters (billing, accounting, compliance), reconcile periodically by reading the underlying resource via the REST API. The REST response is authoritative, the webhook payload reflects the same data at the moment the event was produced.
Disabling a webhook
To pause deliveries (e.g. during a deploy that touches your receiver), contact us and we will set the webhook to disabled. In-flight retries continue until the webhook is re-enabled or the retry window expires. To resume, contact us and we will set it back to active.
Rotating the secret
To rotate the signing secret, contact us. We will create a new webhook with a fresh secret and disable the old one once you have cut over. Plan a brief overlap where your receiver accepts either secret during the cutover.
Common pitfalls
- Parsing the body before verifying the signature. Always verify against the raw bytes. Any whitespace or key-order change breaks the HMAC.
- Using non-constant-time comparison. Use
hmac.compare_digest,crypto.timingSafeEqual, or your language's equivalent. A naive==can leak the secret over time. - Assuming a single delivery per event. Retries and at-least-once delivery mean duplicates happen, dedupe on the envelope
id. - Assuming strict order. Compare the envelope
timeagainst what you have already recorded. - Letting the handler block. Any single attempt that exceeds 30 seconds is retried. Acknowledge fast, process async.
- Trusting
Webhook-Idfor idempotency. It is convenient for support requests but is not protected by the signature. Use the envelopeid.
Support
If a delivery is failing and you cannot tell why, send us the Webhook-Id header from a failed attempt (or the envelope id) and we can look up the delivery in our system.