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",
    "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-Signaturesha256=<hex> — HMAC‑SHA256 of the raw request body, computed with your webhook's signing secret.

Verifying the signature

Compute HMAC-SHA256(secret, raw_request_body) as lowercase hex, prefix it with sha256=, and compare to X-Provable-Signature using a constant‑time comparison. 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_...

// Capture the raw body so the HMAC matches byte-for-byte.
app.post(
    "/hooks/provable",
    express.raw({ type: "application/json" }),
    (req, res) => {
        const header = req.get("X-Provable-Signature") || "";
        const expected =
            "sha256=" +
            crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");

        const a = Buffer.from(header);
        const b = Buffer.from(expected);
        if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
            return res.status(401).send("bad 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
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["PROVABLE_WEBHOOK_SECRET"].encode()  # whsec_...

@app.post("/hooks/provable")
def provable_hook():
    raw = request.get_data()  # raw bytes, before JSON parsing
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-Provable-Signature", "")
    if not hmac.compare_digest(expected, received):
        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'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')"

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?

Compute HMAC-SHA256 of the raw request body using your webhook signing secret, hex-encode it, and constant-time compare to the header value. 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 with exponential backoff for up to several attempts. 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.