RedditapisRedditapis
Live Monitoring

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

HeaderMeaning
x-redditapis-signaturet=<unix_seconds>,v1=<hex hmac>
x-redditapis-timestampThe same unix timestamp, for convenience
x-redditapis-delivery-idStable 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": [ ... ]
}
FieldMeaning
delivery_idSame value as the x-redditapis-delivery-id header. Deduplicate on it.
monitor_idThe monitor that matched.
countHow many items are in this delivery.
groupedWhether several matches were bundled into one delivery.
itemsThe 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.idid
comment.data.scoreupvotes
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:

AttemptDelay before it
1immediate
25 seconds
330 seconds
42 minutes
55 minutes
613 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.

On this page