Monitors and webhooks

A monitor watches something on a schedule and reports only what is new.

curl --request POST \
  --url https://api.weirapp.io/v1/monitors \
  --header "Authorization: Bearer $WEIR_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Checkout complaints",
    "target": {
      "kind": "search",
      "query": "checkout failing",
      "sources": ["reddit", "x"]
    },
    "schedule": "daily"
  }'

What you can watch

target.kind decides what a monitor polls.

Kind Watches Fields
search A phrase, across sources query, sources
subreddit One community subreddit, optional query, optional includeNsfw
subreddit_comments subreddit, optional query, optional includeNsfw
reddit_user username, optional include, optional includeNsfw
x_user One account's posts handle
reddit_thread New comments on one Reddit post postId
x_thread New replies to one X post postId

Leave query off a subreddit target to be told about every new post in the community.

{ "kind": "subreddit", "subreddit": "saas" }
{ "kind": "x_user", "handle": "naval" }
{ "kind": "reddit_thread", "postId": "https://www.reddit.com/r/saas/comments/1vdo8we/slug/" }

Identifiers are normalised, so paste whatever you have. A URL, an r/ prefix, an @, and a t3_ fullname all work.

A thread watch returns comments rather than posts. Each result says which it is in itemType.

An x_user monitor resolves the handle once and follows the account by its numeric id after that, so it keeps working if the account is renamed.

Schedule Runs
manual Only when you call POST /v1/monitors/{id}/run
hourly Every hour. Paid tiers only
daily Every day (the default)
weekly Every week

How many monitors you can keep depends on your tier: one on the free tier, more on paid tiers. GET /v1/limits returns the number for your account. Going over returns 409.

"New" means new content, not a new id

Results are deduplicated by a hash of the content, not by the platform's post id.

That is the reason to use a monitor rather than a cron job around a search. An edited post keeps its id; a re-post gets a fresh one. Deduplicating by id alerts you twice for the second and never for the first.

Reading results

curl --url "https://api.weirapp.io/v1/monitors/$MONITOR_ID/results" \
  --header "Authorization: Bearer $WEIR_API_KEY"

/runs gives the execution history, including failures and what they collected before failing.

Both are free. So is triggering a run. You pay only for the results it collects, at the same rate as running the query by hand. A triggered run answers 202 with a job id and runs in the background, so read /runs for the outcome rather than expecting results in the response.

Webhooks

Be told instead of polling:

curl --request POST \
  --url https://api.weirapp.io/v1/webhooks \
  --header "Authorization: Bearer $WEIR_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "name": "Ops channel",
    "url": "https://hooks.example.com/weir",
    "events": ["monitor.result.created"]
  }'

The signing secret is returned once. Point a monitor at the webhook with its webhookId.

Event Sent when
monitor.result.created A run found something new. One delivery per run, up to 25 items
webhook.test You call POST /v1/webhooks/{id}/test

You can also subscribe to monitor.run.failed, but no deliveries are sent for it yet. Read a monitor's /runs to catch a run that failed.

How many webhooks you can keep depends on your tier, the same way as monitors.

What a delivery looks like

A POST with a JSON body and three headers:

weir-signature: 5f2b…   (hex HMAC-SHA256)
weir-timestamp: 1800000000
weir-event-id: evt_…
{
  "id": "evt_…",
  "type": "monitor.result.created",
  "createdAt": "2026-07-30T11:04:22.000Z",
  "data": {
    "monitorId": "…",
    "runId": "…",
    "kind": "search",
    "itemType": "post",
    "newResults": 3,
    "results": []
  }
}

results holds each platform's own record, exactly as the API returns it.

Verifying a delivery

The signature is HMAC-SHA256(secret, "{timestamp}.{eventId}.{rawBody}"), hex encoded, where timestamp is the weir-timestamp header, eventId the weir-event-id header, and rawBody the body exactly as received.

All three parts are signed on purpose: the body alone could be replayed forever, the timestamp bounds that window, and the event id stops one valid signature being lifted onto another delivery inside it. Reject a timestamp more than five minutes from now, and compare in constant time.

Use the raw body. Parsing and re-serializing JSON reorders keys, which changes the string and breaks the signature. This is the most common reason a correct secret still fails.

Node, with Express:

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

function verifyWeir(raw: Buffer, signature: string, timestamp: string, eventId: string): boolean {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = createHmac("sha256", process.env.WEIR_WEBHOOK_SECRET!)
    .update(`${timestamp}.${eventId}.`)
    .update(raw)
    .digest("hex");
  return (
    signature.length === expected.length &&
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}

const app = express();

app.post("/weir", express.raw({ type: "application/json" }), (request, response) => {
  const valid = verifyWeir(
    request.body,
    request.header("weir-signature") ?? "",
    request.header("weir-timestamp") ?? "",
    request.header("weir-event-id") ?? "",
  );
  if (!valid) return response.status(400).end();

  response.status(200).end();
  const event = JSON.parse(request.body.toString("utf8"));
  console.log(event.id, event.type); // process it here, deduplicating on event.id
});

express.raw, not express.json, so the body arrives as the bytes that were signed.

Python:

import hashlib
import hmac
import os
import time


def verify_weir(raw_body: bytes, signature: str, timestamp: str, event_id: str) -> bool:
    try:
        if abs(time.time() - int(timestamp)) > 300:
            return False
    except ValueError:
        return False
    message = f"{timestamp}.{event_id}.".encode() + raw_body
    secret = os.environ["WEIR_WEBHOOK_SECRET"].encode()
    expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), signature.encode())

Pass the raw bytes: request.get_data() in Flask, await request.body() in Starlette and FastAPI.

Go:

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"time"
)

// VerifyWeir reports whether a delivery was signed with the webhook's secret.
func VerifyWeir(rawBody []byte, signature, timestamp, eventID, secret string) bool {
	issued, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	if age := time.Now().Unix() - issued; age > 300 || age < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "." + eventID + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

Read the body with io.ReadAll(r.Body) before decoding it.

Delivery behaviour

  • At least once. A delivery can arrive more than once. weir-event-id stays the same when a delivery is retried, so deduplicate on it.
  • Retries back off over about nine hours: roughly 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours after each failure. Every attempt is recorded at GET /v1/webhook-deliveries.
  • Respond within 10 seconds. Return 2xx first, then do the work.
  • A 4xx other than 429 is not retried. It says the request itself was refused, and sending it again would be refused the same way.
  • Redirects are not followed. A 302 is a failure. A public URL redirecting to an internal one is the standard way to turn a webhook into request forgery.
  • A persistently failing endpoint is disabled after 20 failures in a row. One success clears the streak.

POST /v1/webhooks/{id}/test sends a real signed delivery, free.

View this page as Markdown