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. |
The payload
The body is JSON. One delivery can carry more than one match, so items is
always an array even when it holds a single entry.
{
"delivery_id": "b6f0a1c2-...",
"monitor_id": "9d2e77f4-...",
"count": 2,
"grouped": false,
"items": [ ... ]
}| Field | Meaning |
|---|---|
delivery_id | Same value as the x-redditapis-delivery-id header. Deduplicate on it. |
monitor_id | The monitor that matched. |
count | How many items are in this delivery. |
grouped | Whether several matches were bundled into one delivery. |
items | The matches themselves. Shape depends on whether the monitor watches posts or comments. |
A comment match
{
"id": "n1abc2d",
"author": "someuser",
"body": "the comment text",
"subreddit": "technicalanalysis",
"upvotes": 12,
"permalink": "/r/technicalanalysis/comments/1abcdef/some_thread/n1abc2d/",
"url": "https://reddit.com/r/technicalanalysis/comments/1abcdef/some_thread/n1abc2d/",
"post_id": "1abcdef",
"link_title": "the title of the parent post",
"link_url": "https://example.com/linked",
"created": "2026-08-25T09:00:00.000Z"
}post_id is the parent post's id with Reddit's t3_ prefix already stripped,
so you can pass it straight to GET /api/reddit/post/{postId}. upvotes is
Reddit's score. created is ISO 8601, not epoch seconds.
This is the same shape deliveries returns
GET /api/reddit/monitor/deliveries?id=<monitor_id> answers with
{
"deliveries": [
{
"delivery_id": "b6f0a1c2-...",
"monitor_id": "9d2e77f4-...",
"webhook_id": "f7a817c8-...",
"status": "delivered",
"attempts": 1,
"last_error": null,
"created_at": "2026-08-25T09:00:01.000Z",
"delivered_at": "2026-08-25T09:00:01.480Z",
"payload": { "count": 2, "grouped": false, "items": [ ... ] }
}
]
}payload.items is byte-for-byte the same shape as the items you receive on
the webhook, so whatever parses your webhook body can be pointed at
deliveries[].payload.items unchanged. The extra fields on a delivery row are
transport state: status, attempts, last_error, created_at,
delivered_at, webhook_id.
It is NOT the same shape as GET /api/reddit/comment/{id}
That endpoint returns Reddit's raw object, passed through untouched:
{ "comment": { "kind": "t1", "data": { "id": "n1abc2d", "score": 12, "link_id": "t3_1abcdef", "created_utc": 1787654400 } } }Monitor items are flattened and normalised; /comment/{id} is not. If you need
to line the two up:
/comment/{id} | Monitor item |
|---|---|
comment.data.id | id |
comment.data.score | upvotes |
comment.data.link_id minus t3_ | post_id |
comment.data.created_utc (epoch seconds) | created (ISO 8601) |
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.
When the matched post is an image submission, the image is rendered too: Slack
gets an image block under the title, Discord gets it as the embed image. This
covers a direct image link on Reddit's own media hosts, which is what an image
post is. A gallery, a v.redd.it video or a link post carries no single image
we can vouch for, so those render as before, with the title and the link. The
image is never re-hosted by us: both platforms fetch it from Reddit.
Independent third-party API for developers and researchers. Not affiliated with, endorsed by, or sponsored by Reddit, Inc.
