Webhooks Overview
Webhooks are how Pipelet pushes OCPP events to your application in real time. Subscribe once with the Headless API, then receive HTTP POSTs whenever a StartTransaction, MeterValues, StatusNotification, etc. arrives.
23 event types are supported — see the Event Reference for the full list.
Lifecycle
Section titled “Lifecycle”1. Create → POST /api/v1/webhooks2. Test → POST /api/v1/webhooks/{id}/test (synthetic event for verification)3. Receive → Pipelet POSTs to your URL whenever a subscribed event fires4. Update → PUT /api/v1/webhooks/{id} (change events, URL, or secret)5. Delete → DELETE /api/v1/webhooks/{id}Creating a webhook
Section titled “Creating a webhook”curl -X POST http://localhost:8080/api/v1/webhooks \ -H "X-API-Key: hcpms_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "My Integration", "url": "https://my-app.example.com/webhooks/ocpp", "enabled": true, "events": [ "StartTransaction", "StopTransaction", "StatusNotification", "MeterValues" ], "secret_header": "X-Webhook-Secret", "secret_value": "my-secret-value-here" }'The response includes a numeric id you can use to update or delete the webhook later.
Payload format
Section titled “Payload format”Every delivery is a JSON POST with this shape:
{ "event": "StartTransaction", "station_id": "WALLBOX_001", "timestamp": "2026-04-14T12:34:56.789Z", "payload": { "connectorId": 1, "idTag": "RFID_12345678", "meterStart": 12345, "timestamp": "2026-04-14T12:34:56Z", "transactionId": 42 }}The payload field contains the raw OCPP message body — schemas are defined by the OCPP 1.6 spec.
Signing & verification
Section titled “Signing & verification”Pipelet authenticates outbound webhooks with a shared secret sent as an HTTP header. You configure the header name (secret_header) and value (secret_value) when creating the webhook.
The simplest receiver pattern:
from flask import Flask, request, abortapp = Flask(__name__)
EXPECTED_SECRET = "my-secret-value-here"
@app.post("/webhooks/ocpp")def receive(): if request.headers.get("X-Webhook-Secret") != EXPECTED_SECRET: abort(401) event = request.json print(f"{event['event']} from {event['station_id']}") return "", 200const express = require("express");const app = express();app.use(express.json());
const EXPECTED = "my-secret-value-here";
app.post("/webhooks/ocpp", (req, res) => { if (req.headers["x-webhook-secret"] !== EXPECTED) { return res.sendStatus(401); } console.log(`${req.body.event} from ${req.body.station_id}`); res.sendStatus(200);});Retry & delivery semantics
Section titled “Retry & delivery semantics”| Property | Value |
|---|---|
| Delivery guarantee | At-least-once |
| Successful response | HTTP 2xx within 10 seconds |
| Retry on failure | Yes — exponential backoff |
| Backoff schedule | 30s, 1m, 5m, 30m, 2h, 6h, 24h |
| Max attempts | 7 (then dead-lettered) |
| Dead-lettering | Failed deliveries land in failed_deliveries table |
| Order | Best-effort chronological — not strictly ordered across stations |
At-least-once means your handler must be idempotent. Use transactionId or (station_id, payload.timestamp) as a dedupe key.
Testing your endpoint
Section titled “Testing your endpoint”Pipelet provides a built-in test endpoint that POSTs a synthetic payload to your webhook URL:
curl -X POST http://localhost:8080/api/v1/webhooks/42/test \ -H "X-API-Key: hcpms_live_..."Response includes the HTTP status your endpoint returned:
{ "ok": true, "status_code": 200, "duration_ms": 124}This is invaluable for staging — you can verify your receiver is reachable, your secret is correct, and your handler doesn’t 500 on the canonical payload shape before any real charger sends data.
Common pitfalls
Section titled “Common pitfalls”| Symptom | Cause | Fix |
|---|---|---|
| Webhooks fire but receiver never sees them | Firewall/NAT in the way | Use a tunnel like ngrok during development |
| 401 from your endpoint | Secret mismatch | Check both header name and value — both are case-sensitive |
| Duplicate events | Your endpoint returned non-2xx, Pipelet retried | Make handler idempotent; return 2xx fast even before processing |
| Order-dependent logic broken | You assumed strict ordering | Look at payload.timestamp, not arrival order |
| Receiver overwhelmed during reconnect storms | All stations re-sending state at once | Batch + debounce on your side, or filter to only the events you need |