Webhooks
Subscribe to events instead of polling. Each delivery is signed with HMAC-SHA256 so your receiver can verify the payload came from us.
Setup
Create a subscription on the /app/webhooks page. You'll be shown the signing secret once, copy it into your environment.
Events
| Event | When |
|---|---|
batch.completed | A cleaning batch finished successfully. Payload includes counters; download URLs are minted on demand from the dashboard or API. |
batch.failed | A cleaning batch failed terminally. Payload includes errorCode. |
Payload shape
POST https://your-receiver.example.com/webhook
Content-Type: application/json
Plc-Signature: v1=<hmac-sha256-hex>
Plc-Timestamp: 1729872321
Plc-Event-Id: evt_abc123_xyz
Plc-Event-Type: batch.completed
{
"id": "evt_abc123_xyz",
"type": "batch.completed",
"createdAt": "2026-06-13T14:22:01.123Z",
"data": {
"batch": {
"id": "df3a...",
"totalRows": 10000,
"uniqueNumbers": 8420,
"duplicateRows": 1500,
"processedRows": 10000,
"successfulRows": 9580,
"failedRows": 80,
"creditsUsed": 8420,
"completedAt": "2026-06-13T14:22:01.000Z"
}
}
}Verifying the signature
Each delivery's Plc-Signature header is v1=<hex> where the hex is HMAC_SHA256(signing_key, "<timestamp>.<body>"). The signing key is the SHA-256 of your raw secret, that way we never need to store the raw value and you derive the same key deterministically.
Node.js verifier
import { createHash, createHmac, timingSafeEqual } from "crypto";
const PLC_SECRET = process.env.PLC_WEBHOOK_SECRET; // raw, plcsk_*
const TOLERANCE_SEC = 5 * 60;
function verify(req) {
const signature = req.headers["plc-signature"];
const timestamp = Number(req.headers["plc-timestamp"]);
const body = req.rawBody; // raw bytes
if (!signature || !timestamp || !body) return false;
// Reject replays.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > TOLERANCE_SEC) return false;
const signingKey = createHash("sha256").update(PLC_SECRET).digest("hex");
const expected = "v1=" +
createHmac("sha256", signingKey)
.update(`${timestamp}.${body.toString("utf8")}`)
.digest("hex");
return signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}Shell verifier
#!/usr/bin/env bash
SECRET="$PLC_WEBHOOK_SECRET"
SIGNING_KEY=$(echo -n "$SECRET" | shasum -a 256 | awk '{print $1}')
SIGNED="$PLC_TIMESTAMP.$REQUEST_BODY"
EXPECTED="v1=$(printf '%s' "$SIGNED" | openssl dgst -sha256 -hmac "$SIGNING_KEY" | awk '{print $2}')"
[[ "$EXPECTED" == "$PLC_SIGNATURE" ]] && echo okRetries
Any non-2xx response, network error, or timeout (10s) triggers a retry. The schedule is:
- Attempt 1, immediate
- Attempt 2, +1 minute
- Attempt 3, +5 minutes
- Attempt 4, +30 minutes
- Attempt 5, +3 hours
- After attempt 5, delivery marked
abandoned
Idempotency: each event has a stable id (also in the Plc-Event-Id header). Retries keep the same id. Use it as a dedupe key on your end.
Auto-disable
After 10 consecutive abandoned events (each one having burned all 5 retries), we automatically flip the subscription to active=false and log an AdminEvent. New events stop firing. Re-enable from the /app/webhooks page once you've fixed the receiver, re-activating resets the failure counter.
Security
- HTTPS receivers only in production.
- Verify the signature on every request, don't trust the URL alone.
- Reject deliveries where
Plc-Timestampis more than 5 minutes off your clock. - Rotate the secret by creating a new subscription and deleting the old one. We don't support in-place rotation in v1.