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
outcome.created— emitted for every outcome produced by an authenticated call to/api/floats,/api/ints,/api/shuffle,/api/pick,/api/bytes,/api/dice,/api/gaussian,/api/reveal, the batch endpoint, and the streaming endpoint (one event per emitted outcome).test— emitted when you click Send test event in the dashboard. The eventdatais{ "message": "This is a test event from Provable.io" }.
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"
}
}
event— one of the event types above.eventId— unique per event and unchanged across retries. Record the IDs you have processed and ignore repeats: that makes retries safe and stops a captured request from being replayed.timestamp— ISO‑8601 UTC timestamp of when the event was generated (when the webhook was queued, not the outcome itself).data— event-specific payload. Foroutcome.created, the fields are:endpoint— the API path that produced the outcome, e.g."/api/ints","/api/shuffle","/api/reveal".outcomeId— stable identifier of the form"{clientSeed}:{cursor}:{nonce}"; useful as a de‑duplication key on your side.clientSeed— the client seed supplied on the originating request.serverSeedHash— the server seed hash you can use to verify the outcome via/api/verifyServerHash.nonce,cursor— the seed-chain position of this outcome.values— the produced outcome (array of numbers, shuffled list, picked items, byte string, etc. — shape depends on the endpoint).count,min,max— request parameters when applicable; may benull/absent for endpoints that don't use them.createdAt— ISO‑8601 timestamp the outcome itself was created on the server.
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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Provable-Webhook/1.0 |
X-Provable-Event | The event type (e.g. outcome.created, test). |
X-Provable-Signature | t=<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.
- Up to 6 attempts per event (1 initial + 5 retries), all carrying the same
eventId. - Exponential backoff between attempts: 30 s, 1 min, 2 min, 4 min, 8 min, plus up to 30 s of jitter — about 15 minutes in total, enough to ride out a deploy or restart of your receiver.
- If all attempts fail, the webhook's Last delivery in the dashboard switches to failed with the last error. The event is not re-queued — design your receiver to be tolerant of occasional missed events and reconcile from the API when needed.
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
- Endpoint must be reachable over public
http://orhttps://. Private, loopback, and link-local addresses are rejected at create time and re-checked on every delivery. - Embedded credentials in the URL (
https://user:pass@…) are not allowed. - Redirects are not followed — point the webhook at the final URL.
- Return
2xxas soon as the signature verifies; do business logic after responding.
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.