What a delivery looks like
Each delivery is an HTTPS POST with Content-Type application/json and User-Agent PrizePicksAPI-Webhooks/1.0. Three headers matter: X-Webhook-Id (unique per endpoint and event), X-Webhook-Timestamp (Unix seconds), and X-Webhook-Signature in the form v1=<64 hex characters>.
{
"id": "123456",
"type": "offer.changed",
"created_at": "2026-01-01T00:00:00Z",
"data": {
"type": "offer.changed",
"projection_id": "…",
"league": "NBA",
"player_name": "…",
"stat": "Points",
"previous_line": 24.5,
"line": 25.5,
"observed_at": "2026-01-01T00:00:00Z"
}
}The values are illustrative. Subscribable types are offer.new, offer.changed, offer.unavailable, and offer.restored, matching the change feed. The top-level id is the change event ID, so a webhook and a /changes poll describe the same event with the same identifier.
Verify the signature in Node.js
The signature is HMAC-SHA256, keyed with your endpoint’s signing secret, over the timestamp, a period, and the exact raw request body. Verify against the raw bytes: parsing and re-serializing JSON changes whitespace and breaks the comparison.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody, headers, secret, nowMs = Date.now()) {
const ts = headers["x-webhook-timestamp"] ?? "";
const sig = headers["x-webhook-signature"] ?? "";
if (!/^\d+$/.test(ts) || Math.abs(nowMs / 1000 - Number(ts)) > 300) return false; // replay window
const match = /^v1=([a-f0-9]{64})$/.exec(sig);
if (!match) return false;
const expected = createHmac("sha256", secret).update(ts + "." + rawBody).digest();
return timingSafeEqual(expected, Buffer.from(match[1], "hex"));
}
// Express: keep the raw body for this route only.
// app.post("/hooks/prizepicks", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyWebhook(req.body.toString("utf8"), req.headers, process.env.PP_WEBHOOK_SECRET)) return res.sendStatus(401);
// enqueue(JSON.parse(req.body)); // do the real work asynchronously
// res.sendStatus(204);
// });Verify the signature in Python
import hashlib, hmac, re, time
def verify_webhook(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp", "")
sig = headers.get("X-Webhook-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
match = re.fullmatch(r"v1=([a-f0-9]{64})", sig)
if not match:
return False
expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(1))The five-minute window rejects captured requests replayed later; keep your server clock synchronized. The signing secret starts with whsec_ and is returned once when you create the endpoint. Store it in a secret manager. It is separate from your API key.
Acknowledge fast, process idempotently
- Return a 2xx status as soon as the request is verified and queued. The sender waits about seven seconds before treating the attempt as failed.
- Redirects are not followed. A 301 or 302 counts as a failure, so register the final URL.
- Deduplicate on X-Webhook-Id or the payload id. A retry after a lost acknowledgement delivers the same event again.
- Do not assume order. Use observed_at and the numeric event id when ordering matters.
- For offer.changed, compare previous_line and line before sending a line-movement alert; other fields can cause a change event.
Retry schedule and endpoint rules
| After failed attempt | Next attempt is scheduled |
|---|---|
| 1 | about 1 minute later |
| 2 | about 2 minutes later |
| 3 | about 4 minutes later |
| 4 | about 8 minutes later |
| 5 | no further attempts; marked failed |
Timings depend on when the delivery worker runs, so treat them as minimums. Endpoints must use HTTPS on port 443, contain no credentials or fragment, and resolve only to public IPv4 addresses; localhost, private ranges, and internal hostnames are rejected. The connection goes to the address validated at send time, which prevents DNS rebinding. Recent delivery attempts and status codes appear in the webhooks workspace.
Frequently asked questions
Which plan includes webhooks?
Webhook delivery requires the Production plan. Endpoints on other plans are not sent deliveries.
How many times is a failed delivery retried?
Up to five attempts in total, with exponential backoff between attempts. A non-2xx status, timeout, or connection error counts as a failure; after the fifth failure the delivery is marked failed.
References
Current API documentation ↗Webhook endpoints workspace ↗Node.js: crypto.timingSafeEqual ↗Python: hmac.compare_digest ↗PrizePicks API is an independent data service. It is not affiliated with or endorsed by PrizePicks. Observations, completeness, and freshness are separate claims; consult source status before relying on a stored line.