Skip to content

Decision webhooks

A decision card records what your team answered. Instead of polling the pull API, you can give PingLoom a URL and it will POST the result there when the decision is decided or expires. PingLoom still never performs the action: your server does.

Note: Decision webhooks are included in the Team and Business plans. The pull API, GET /v1/decisions, works on every plan that has decision cards, and remains the safety net if your server is down.

Set it up

Open a project, then Settings, then Decision webhook. Only owners and admins can see this section. Enter an https URL, choose which events to receive, and save. PingLoom shows the signing secret once: store it in your server's configuration, never in client code. Use Send test to check your endpoint, and the delivery log to see every attempt and resend one.

The request

PingLoom sends a POST with a JSON body and these headers. The body is the same decision object the pull API returns, wrapped in type, id and created_at. The types are decision.decided, decision.expired and, for the test button, decision.test.

headers
POST /pingloom HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: PingLoom-Webhooks/1
PingLoom-Event: decision.decided
PingLoom-Delivery-Id: 01JBX4K2Z8Q3N6M5T7R9V1W0YA
PingLoom-Signature: t=1761902042,v1=5b1c0c4e...

body
{
  "type": "decision.decided",
  "id": "01JBX4K2Z8Q3N6M5T7R9V1W0YA",
  "created_at": "2026-10-31T09:14:02.511000Z",
  "data": {
    "event_id": "01JBX4JXQ0E5G2C8D4F6H8K1MN",
    "external_id": "order-8812",
    "channel": "payments",
    "created_at": "2026-10-31T09:10:41.000000Z",
    "changed_at": "2026-10-31T09:14:02.000000Z",
    "decision": {
      "status": "decided",
      "prompt": "Refund order 8812?",
      "options": [{ "id": "refund_full", "label": "Refund in full" }, { "id": "reject", "label": "Reject" }],
      "expires_at": "2026-10-31T11:10:41.000000Z",
      "option_id": "refund_full",
      "label": "Refund in full",
      "comment": null,
      "decided_by": { "user_id": "01J9ZQ...", "name": "Aanya" },
      "decided_at": "2026-10-31T09:14:02.000000Z",
      "via": "mobile"
    },
    "data": { "order_id": 8812 }
  }
}

Reply with any 2xx status within 10 seconds. PingLoom reads at most 64 KB of your answer and keeps only the first 1 KB, plus the status code and timing, in the delivery log. It never stores your response headers.

Verify the signature

The PingLoom-Signature header is t=<unix seconds>,v1=<hex>. The value is the HMAC-SHA256 of the string t + "." + raw_body, keyed with your signing secret. Compute it over the exact bytes you received, compare in constant time, and reject a timestamp more than 5 minutes from your clock. A new timestamp is signed for every attempt, so retries pass the check.

Python

Python
import hashlib, hmac, time

TOLERANCE = 300  # seconds

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    try:
        fields = dict(part.split("=", 1) for part in header.split(","))
        timestamp, given = int(fields["t"]), fields["v1"]
    except (ValueError, KeyError):
        return False
    if abs(time.time() - timestamp) > TOLERANCE:
        return False
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, given)

# Flask example. Use the raw bytes, not a re-serialised JSON object.
@app.post("/pingloom")
def pingloom():
    raw = request.get_data()
    if not verify(SECRET, request.headers.get("PingLoom-Signature", ""), raw):
        return "bad signature", 400
    event = json.loads(raw)
    if already_processed(event["id"]):
        return "", 200
    handle(event["data"])
    mark_processed(event["id"])
    return "", 200

Node

Node
import crypto from "node:crypto";

const TOLERANCE = 300; // seconds

export function verify(secret, header, rawBody) {
  const fields = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(fields.t);
  if (!Number.isInteger(timestamp) || !fields.v1) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(fields.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express example. express.raw keeps the exact bytes PingLoom signed.
app.post("/pingloom", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(process.env.PINGLOOM_SECRET, req.get("PingLoom-Signature") ?? "", req.body)) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(req.body);
  if (alreadyProcessed(event.id)) return res.sendStatus(200);
  handle(event.data);
  markProcessed(event.id);
  res.sendStatus(200);
});

Careful: Rotating the secret takes effect at once. Update your server first, or have it accept both secrets for a few minutes, then rotate.

Retries and duplicates

A failed attempt (a non-2xx answer, a timeout or a connection error) is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. After the sixth failure the delivery is marked as given up. You can resend it from the delivery log, which creates a new delivery.

Delivery is at least once. Store the id (also sent as PingLoom-Delivery-Id) of what you have handled and ignore repeats. A resend has a new id, so to protect the business action itself also key on data.event_id.

What PingLoom will call

This is the only place PingLoom connects to an address you supplied, so it is deliberately strict.

  • https only, at most 2,048 characters, no username or password in the URL. Port 443, or a port from 1024 up.
  • PingLoom resolves your host name itself and refuses private, loopback, link-local, carrier-grade NAT, multicast and cloud metadata addresses, for IPv4 and IPv6. It then connects to the address it checked, with your host name as the TLS server name and Host header.
  • Redirects are never followed: a 3xx answer counts as a failure. Point the URL at the final address.
  • Connect timeout 3 seconds, total 10 seconds. No cookies, and no proxy settings are used.
  • Your TLS certificate must be valid for the host name.