Webhooks
Verify the HMAC-SHA256 signature, deduplicate on the delivery id, and understand the retry ladder before a delivery reaches you.
Every match is delivered as an HTTPS POST to the endpoint you registered.
Headers
| Header | Meaning |
|---|---|
x-redditapis-signature | t=<unix_seconds>,v1=<hex hmac> |
x-redditapis-timestamp | The same unix timestamp, for convenience |
x-redditapis-delivery-id | Stable id for this delivery. Deduplicate on this. |
Verifying the signature
The signed string is the timestamp, a literal dot, then the raw request body:
HMAC-SHA256(secret, `${timestamp}.${rawBody}`)Sign the bytes you received, not a re-serialised object. JSON.parse followed
by JSON.stringify can reorder keys or change number formatting, and the
signature will then fail for reasons that look like a wrong secret.
const crypto = require("node:crypto");
function verify(secret, header, rawBody, toleranceSeconds = 300) {
// header looks like: t=1786290000,v1=ab12...
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Reject stale timestamps, or a captured request can be replayed forever.
const age = Math.abs(Math.floor(Date.now() / 1000) - t);
if (age > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// Constant-time compare. A plain === leaks timing information about how much
// of the signature matched, which is enough to forge one byte at a time.
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 || "", "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))The tolerance window is 300 seconds. Anything older is a replay.
Retries
A delivery is retried when the attempt is worth repeating: a network failure, a 5xx, or a 429. Other 4xx responses are not retried, because a 400 or a 404 means the request will fail again and hammering someone's server with our name on it is abuse rather than persistence.
The ladder is front-loaded so a brief blip recovers quickly while a long outage backs off, and jittered so every customer's retries do not land on the same second after a shared incident:
| Attempt | Delay before it |
|---|---|
| 1 | immediate |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 13 minutes |
Six attempts over roughly 21 minutes. After that the delivery is dead-lettered and visible on the monitor's health endpoint.
Respond 2xx as soon as you have durably accepted the payload, and do the work afterwards. Holding the connection open while you process makes slow work look like a failure and earns you a retry you did not need.
Secrets
The secret is shown once, when you create the webhook. We store it for
signing and cannot show it again. Rotate it with
POST /reddit/monitor/webhook/create and delete the old target, which lets you
run both briefly during a cutover.
URL rules
Registered URLs must be HTTPS and must resolve to a public address. Private ranges, loopback, link-local and cloud metadata addresses are rejected.
This is checked at delivery time, not only at registration, because a hostname that resolves publicly when you register it can resolve privately later. A URL that passes once is not trusted forever.
Slack and Discord
Register a webhook with kind set to slack or discord and paste the
incoming-webhook URL from that platform. The payload is shaped for that
service instead of our JSON envelope, so no receiving code is needed. Signature
headers do not apply, since the platform authenticates the URL itself.
Independent third-party API for developers and researchers. Not affiliated with, endorsed by, or sponsored by Reddit, Inc.
