Developers
Webhooks
PlugLog sends a signed JSON POST to your https URL for each event you pick, up to 5 URLs per account. Add one on Integrations › Webhooks & Zapier. No code? Use PlugLog on Zapier: the same events, with a Zapier key from that page.
Events
- ⚡ Charge started
- Code
charge.started- Sent when
- A charge session starts.
- ✅ Charge finished
- Code
charge.finished- Sent when
- A charge is over and saved (once per charge).
- ⚠️ Charging stopped early
- Code
charge.stopped- Sent when
- A charge ended 5 or more points below the vehicle's charge limit (only when the limit is known).
- 🔋 Battery reached a %
- Code
battery.reached- Sent when
- The battery goes up to the % you pick (default 80%).
- 🪫 Battery below a %
- Code
battery.low- Sent when
- The battery goes down to the % you pick (default 20%), by default only while not plugged in.
- 📡 Not heard from in N hours
- Code
vehicle.silent- Sent when
- No data from the vehicle for the hours you pick (default 12), once per silence.
- 🚗 Drive started
- Code
drive.started- Sent when
- A drive starts.
- 🏁 Drive ended
- Code
drive.ended- Sent when
- A drive is over and saved (once per drive); optionally business drives only.
- 🗺️ Trip arrived
- Code
trip.arrived- Sent when
- A planned trip's last stop is reached.
- 🏅 Badge earned
- Code
badge.earned- Sent when
- A badge, or its next tier, is earned.
- 📅 Monthly cost summary
- Code
summary.monthly- Sent when
- On the 1st of the month: last month's totals for each vehicle.
| Event | Code | Sent when |
|---|---|---|
| ⚡ Charge started | charge.started | A charge session starts. |
| ✅ Charge finished | charge.finished | A charge is over and saved (once per charge). |
| ⚠️ Charging stopped early | charge.stopped | A charge ended 5 or more points below the vehicle's charge limit (only when the limit is known). |
| 🔋 Battery reached a % | battery.reached | The battery goes up to the % you pick (default 80%). |
| 🪫 Battery below a % | battery.low | The battery goes down to the % you pick (default 20%), by default only while not plugged in. |
| 📡 Not heard from in N hours | vehicle.silent | No data from the vehicle for the hours you pick (default 12), once per silence. |
| 🚗 Drive started | drive.started | A drive starts. |
| 🏁 Drive ended | drive.ended | A drive is over and saved (once per drive); optionally business drives only. |
| 🗺️ Trip arrived | trip.arrived | A planned trip's last stop is reached. |
| 🏅 Badge earned | badge.earned | A badge, or its next tier, is earned. |
| 📅 Monthly cost summary | summary.monthly | On the 1st of the month: last month's totals for each vehicle. |
Each event goes once per webhook and object: a charge saved again never sends a second charge.finished.
Payload
Every message has id (unique per event), event, at (UTC), vehicle and the one object for its event: charge, drive, battery, trip, badge, silence or summary. Distances and efficiency are in your account's units. Send test adds "test": true.
{
"id": "evt_3kq9ZbVn1xQe0pTa7cWm",
"event": "charge.finished",
"at": "2026-10-30T22:14:05Z",
"vehicle": {
"id": "sample-vehicle",
"name": "Silverado EV",
"make": "Chevrolet",
"model": "Silverado EV",
"year": 2024
},
"charge": {
"id": "sample-charge",
"startedAt": "2026-10-30T17:14:05Z",
"endedAt": "2026-10-30T22:14:05Z",
"startPercent": 22,
"endPercent": 80,
"kwhAdded": 118.4,
"cost": 14.21,
"currency": "USD",
"place": "Home",
"maxKw": 11.3,
"url": "https://pluglog.app/charges/sample-charge"
}
}Places are names: “Home”, a saved charging location or favorite, the charger's name, or the nearest city (“near Fort Wayne, IN”). Exact coordinates (lat/lon, a drive's start/end) are only sent when you turn them on for that webhook, and never inside your privacy zones.
Headers
Content-Type: application/jsonUser-Agent: PlugLog-Webhooks/1X-PlugLog-Event: the event's codeX-PlugLog-Delivery: this delivery's id, the same on every retryX-PlugLog-Signature: t=<unix seconds>,v1=<hex>: HMAC-SHA256 of<t>.<raw body>with your signing secret
Answering and retries
Answer with any 2xx within 10 seconds. Anything else (a redirect too: they aren't followed) is tried again after 1 min, 5 min, 20 min, 1 h, 3 h, 6 h, 12 h: 8 tries in all. A URL failing for a whole day is turned off and you're emailed; turn it back on once it's fixed. Every delivery is in its webhook's list for 30 days, with its payload and Resend.
Verifying the signature
Check it on the raw body, before parsing it, with the webhook's signing secret (whsec_…, shown once when you add it). Refuse old timestamps so a copied message can't be sent again.
Node.js
import crypto from "node:crypto";
// rawBody: the request body exactly as it arrived (a string or Buffer), before any JSON parsing.
export function verifyPlugLog(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: keep the raw body for checking.
app.post("/pluglog", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyPlugLog(req.body.toString("utf8"), req.get("X-PlugLog-Signature") ?? "", process.env.PLUGLOG_SECRET)) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Seen X-PlugLog-Delivery before? It's a retry: answer 200 again.
res.sendStatus(200);
});Python
import hashlib, hmac, time
def verify_pluglog(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask: request.get_data() is the raw body.
# if not verify_pluglog(request.get_data(), request.headers.get("X-PlugLog-Signature", ""), SECRET): abort(401)