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

EventWhen
batch.completedA cleaning batch finished successfully. Payload includes counters; download URLs are minted on demand from the dashboard or API.
batch.failedA 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 ok

Retries

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-Timestamp is 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.