Overview

When an authenticated API call generates an outcome, Provable.io will POST a signed JSON event to every webhook URL configured on the account. Configure webhooks from the Webhooks tab on your dashboard — each one is created with a signing secret (whsec_…) that is shown once.

Test-mode keys (pk_test_…) intentionally skip webhook delivery so playground traffic does not pollute downstream consumers.

Event types

Payload schema

Every event is a JSON object with the same top-level shape:

{
    "event": "outcome.created",
    "eventId": "6f1c2b0e-8d4a-4e5f-9b7a-2c3d4e5f6a7b",
    "timestamp": "2026-05-24T18:21:07.412Z",
    "data": {
        "endpoint": "/api/ints",
        "outcomeId": "my-app-user-42:0:1",
        "clientSeed": "my-app-user-42",
        "serverSeedHash": "b4c1…f2",
        "nonce": 1,
        "cursor": 0,
        "values": [37, 8, 91, 42, 14],
        "count": 5,
        "min": 1,
        "max": 100,
        "createdAt": "2026-05-24T18:21:07.401Z"
    }
}

Note: the webhook event is not a 1:1 copy of the HTTP response body. Fields are renamed for the webhook contract (values instead of the API's outcome, serverSeedHash instead of serverHash), and webhook-only fields like outcomeId and endpoint are added.

Request headers

HeaderValue
Content-Typeapplication/json
User-AgentProvable-Webhook/1.0
X-Provable-EventThe event type (e.g. outcome.created, test).
X-Provable-Signaturet=<unix>,sha256=<hex> — the send time in Unix seconds, and HMAC‑SHA256 of <t>.<raw body> computed with your webhook's signing secret. Every attempt, retries included, is signed with its own send time.

Verifying the signature

Split X-Provable-Signature on commas into t and sha256. Compute HMAC-SHA256(secret, t + "." + raw_request_body) as lowercase hex and compare it to sha256 using a constant‑time comparison. Then reject the request if t is more than 5 minutes from your clock: the timestamp is inside the signature, so a captured request can't be replayed later. Use the raw bytes of the body — re-serializing parsed JSON will change the input and break verification.

Node.js (Express)

const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.PROVABLE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes ago

// Capture the raw body so the HMAC matches byte-for-byte.
app.post(
    "/hooks/provable",
    express.raw({ type: "application/json" }),
    (req, res) => {
        // X-Provable-Signature: t=1790720000,sha256=5f2b...
        const sig = Object.fromEntries(
            (req.get("X-Provable-Signature") || "").split(",").map((p) => p.split("=")),
        );
        const expected = crypto
            .createHmac("sha256", SECRET)
            .update(`${sig.t}.`)
            .update(req.body)
            .digest("hex");

        const a = Buffer.from(sig.sha256 || "");
        const b = Buffer.from(expected);
        if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
            return res.status(401).send("bad signature");
        }
        if (Math.abs(Date.now() / 1000 - Number(sig.t)) > TOLERANCE_S) {
            return res.status(401).send("stale signature");
        }

        const event = JSON.parse(req.body.toString("utf8"));
        console.log(event.event, event.data);
        res.status(200).send("ok");
    },
);

app.listen(3000);

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PROVABLE_WEBHOOK_SECRET"].encode()  # whsec_...
TOLERANCE_S = 300  # reject deliveries signed more than 5 minutes ago

@app.post("/hooks/provable")
def provable_hook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    header = request.headers.get("X-Provable-Signature", "")
    sig = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = sig.get("t", "")
    expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig.get("sha256", "")):
        abort(401)
    if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_S:
        abort(401)

    event = request.get_json()
    print(event["event"], event["data"])
    return "ok", 200

curl (compute a signature manually)

Useful for testing your verification code against a fixed body:

BODY='{"event":"test","timestamp":"2026-05-24T18:21:07.412Z","data":{"message":"hi"}}'
SECRET='whsec_replace_me'
T=$(date +%s)
SIG="t=$T,sha256=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')"

curl -X POST https://your-app.example.com/hooks/provable \
    -H "Content-Type: application/json" \
    -H "X-Provable-Event: test" \
    -H "X-Provable-Signature: $SIG" \
    --data "$BODY"

Retries & backoff

A delivery counts as successful when your endpoint returns an HTTP 2xx status within the timeout. Any other response — including 3xx redirects, which are not followed — is treated as a failure.

Timeouts

Each HTTP attempt is given 5 seconds before it is aborted and counted as a failed attempt. Acknowledge fast (return 2xx immediately) and do any slow work asynchronously on your side.

Receiver requirements

Next steps

FAQ

When does Provable.io send a webhook?

Whenever a live-mode API key on your account produces an outcome. Each delivery includes the outcome payload, the serverHash, and a short-ID permalink, so your downstream system can persist or react to it without polling.

How do I verify the X-Provable-Signature header?

The header is t=<unix>,sha256=<hex>. Compute HMAC-SHA256 of t, a dot, and the raw request body using your webhook signing secret, hex-encode it, and constant-time compare it to the sha256 value; then reject t more than 5 minutes from your clock. If it doesn't match, reject the request — the payload didn't come from us (or was tampered with in transit).

How many retry attempts are made?

Failed deliveries are retried up to 5 times with exponential backoff (30 s, 1, 2, 4 and 8 minutes), about 15 minutes in total, and every retry carries the same eventId. The full attempt history is visible in your dashboard under webhook deliveries, with age and per-webhook count retention applied.

What is the per-attempt timeout?

A few seconds per attempt. Your endpoint should respond 2xx quickly and offload heavy processing to a background worker — long requests will be timed out and retried.

Do test-mode (pk_test_*) keys trigger webhooks?

No. Test-mode keys don't touch live seed state and don't fire webhooks, so you can experiment freely without polluting your production delivery history.