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",
"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.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 | sha256=<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.
- Up to 4 attempts per event (1 initial + 3 retries).
- Exponential backoff between attempts: 500 ms, 1 s, 2 s.
- 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.