← All guides
Notifications

Receiving and Verifying a Verid Webhook

The webhook payload Verid sends, the Verid-Signature header, and a copy-pasteable signature verification function that rejects forged and replayed requests.

Verid Guides·12 min read

What a webhook delivery is

When a check finds a change that passes your predicate, Verid sends an HTTP POST to every webhook URL on the monitor. The body is JSON describing what changed. Your server reads it and does whatever you need: updates a database row, opens a ticket, posts to an internal tool.

The catch is that your webhook URL is just a URL. Anyone who learns it can send a POST to it with a JSON body that looks exactly like one of ours. So every delivery carries a signature in a header called Verid-Signature, computed with a secret that only Verid and you know. Checking that signature before you act on the body is what webhook signature verification means, and it is the only thing standing between your endpoint and a stranger who guessed the URL.

This guide covers the payload you receive, how the signature is built, a verifier you can paste into your code, and the handful of ways verification fails even when your code looks right.

The webhook payload

Every delivery has the same outer shape. The part that tells you what changed depends on the kind of monitor.

Field monitors (CSS, XPath, JSONPath, regex, full page, or AI extraction) put the change in diff. fields_changed lists the field names that changed, and before and after hold the extracted values from the previous run and this one:

{
  "id": "del_1758628800123",
  "version": "2026-05-01",
  "monitor_id": "a3f1c2d4-6b7e-4f80-9a1b-2c3d4e5f6071",
  "run_id": "e8d7c6b5-a493-4821-b0c9-d8e7f6a5b4c3",
  "fired_at": "2026-09-23T12:00:00.000Z",
  "diff": {
    "fields_changed": ["price"],
    "before": { "price": "49.99", "stock": "In stock" },
    "after":  { "price": "44.99", "stock": "In stock" }
  },
  "monitor": {
    "url": "https://example.com/product",
    "name": "Product page price"
  }
}

What each top-level field is:

  • id - a label for this delivery, in the form del_ followed by a millisecond timestamp. It stays the same when the delivery is retried. It is not guaranteed to be unique across monitors, so do not use it as a database key.
  • version - the payload schema version, currently 2026-05-01. If the shape ever changes, this value changes with it, so you can branch on it.
  • monitor_id and run_id - which monitor fired, and which check found the change. Every check has its own run_id, and it stays the same across retries.
  • fired_at - when the delivery was created, right after the check found the change, as an ISO 8601 timestamp in UTC. Retries keep the original value.
  • monitor - the monitored URL and the monitor's name, so a human-readable message needs no second API call.

Note that before and after contain every extracted field, not only the changed ones. In the example, stock appears on both sides with the same value. Use fields_changed to know what moved, and before/after to read values.

Visual monitors have no extracted fields, so diff arrives with an empty list and two empty objects. The change is carried in a visual block instead:

{
  "diff": { "fields_changed": [], "before": {}, "after": {} },
  "visual": {
    "change_pct": 4.2,
    "screenshot_url": "https://…",
    "diff_image_url": "https://…"
  }
}

change_pct is how much of the page changed, as a percentage. screenshot_url is the new capture and diff_image_url highlights the changed pixels (it can be null). Both are signed links that stay valid for 7 days, so download the images if you need to keep them longer.

SERP monitors also send an empty diff, with a serp block holding keyword, target_domain, rank, previous_rank, and rank_delta. SERP monitors in full analysis mode send a serp_analysis block with the keyword and the full list of page one results instead.

The failure mode here is writing a receiver that reads diff.after unconditionally. On a visual or SERP monitor that object is always empty, your code sees no change, and it does nothing, silently. Check which block is present before you read from it.

The Verid-Signature header

Every webhook delivery arrives with these headers:

Content-Type: application/json
User-Agent: Verid/1.0
Verid-Signature: t=1758628800,v1=6f1c0b9e…d24a

Verid-Signature has two parts, separated by a comma:

  • t is a Unix timestamp in seconds: the moment this delivery attempt was signed.
  • v1 is the signature itself, a 64-character lowercase hex string.

The signature is an HMAC-SHA256 of a string made by joining the timestamp, a full stop, and the exact request body:

signed_payload = "{t}.{raw_body}"
v1 = hex( HMAC-SHA256( key = your_signing_secret, message = signed_payload ) )

To verify, you do the same computation on your side and check that your result equals v1. If someone changed a single byte of the body, or signed it with a different key, the two will not match.

The timestamp is inside the signed string on purpose. It means an attacker who captures a real delivery cannot change t to make an old request look new, because that would break the signature. Which brings us to replays.

Why the timestamp check matters

A valid signature proves a request came from Verid. It does not prove the request is fresh. Someone who intercepts one genuine delivery could send the identical bytes to your endpoint again tomorrow, and the signature would still check out.

So a verifier also rejects any request whose t is too far from your server's current time. The default tolerance is 300 seconds (5 minutes), in either direction. That window is wide enough to absorb normal network delay and small clock differences between our servers and yours, and narrow enough that a captured request is useless shortly after it was sent.

Retries do not trip this check. Verid signs each attempt at the moment it is sent, so a delivery retried an hour later carries a fresh t and a fresh signature over the same body.

The failure mode: a server whose clock has drifted more than five minutes rejects every genuine delivery. If verification suddenly fails for all requests at once, check that your server syncs its clock (NTP) before you suspect the secret.

Getting your signing secret

Each monitor has its own signing secret. It is a 64-character hex string with no prefix, created automatically the first time the monitor gets a webhook delivery, whether you add the webhook when you create the monitor or later when you edit it. The secret does not change after that, so you can store it once.

In the dashboard: open the monitor and go to its Config tab (on a SERP monitor the card is on the main page). Monitors with a webhook delivery show a Webhook verification card. Click Reveal secret, then Copy.

Over the API:

GET /v1/monitors/{id}/signing-secret
{ "secret": "3f9c1a…e21a" }

secret is null when the monitor has never had a webhook delivery, because there is nothing signed to verify. SERP monitors have the same endpoint at /v1/serp-monitors/{id}/signing-secret.

The secret is deliberately left out of the normal monitor responses (listing monitors or fetching one monitor never includes it). You get it only by asking for it explicitly, so it does not leak into logs or tools that happen to read monitor objects. Store it in your own secret manager or an environment variable, never in source code.

If a delivery reaches you with no Verid-Signature header at all, the monitor has no secret yet. Open the monitor, edit it, and save it with its webhook delivery in place. A secret is generated on that save, and every delivery from then on is signed.

A verification function you can copy

Here is a complete verifier in Node.js. It rejects a missing or malformed header, a stale timestamp, and a wrong signature, and it compares signatures in constant time:

import { createHmac, timingSafeEqual } from 'crypto';

export function verifyVeridSignature(
  header: string | null,
  rawBody: string,
  secret: string,
  toleranceSecs = 300,
): boolean {
  if (!header) return false;

  // Parse "t=...,v1=..."
  let timestamp: number | null = null;
  let signature: string | null = null;
  for (const part of header.split(',')) {
    const [key, value] = part.split('=');
    if (key === 't' && value) timestamp = parseInt(value, 10);
    if (key === 'v1' && value) signature = value;
  }
  if (!timestamp || !signature) return false;

  // Reject stale or future-dated requests (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > toleranceSecs) return false;

  // Recompute the signature over "{t}.{raw body}"
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Constant-time comparison; lengths must match first
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

Why constant time: an ordinary === string comparison stops at the first character that differs, so it answers slightly faster the less of the signature matches. With enough requests, an attacker can measure those tiny differences and work out a valid signature one character at a time. timingSafeEqual takes the same time whether the signatures match or not. The length check comes first because timingSafeEqual throws on buffers of different lengths instead of returning false.

The same function in Python, Ruby, Go, and PHP is on the webhooks reference page. Every language has a constant-time compare built in (hmac.compare_digest, hmac.Equal, hash_equals); use it rather than ==.

Verify the raw body, before you parse it

This is the mistake behind most "my signature never matches" reports. The signature covers the exact bytes Verid sent. If your framework parses the JSON first and you re-serialise it to verify, the result can differ in whitespace, key order, or number formatting, and the signature fails even though the data is identical.

So read the body as raw text, verify that text, and only then parse it. How to get the raw body depends on your framework:

Express: use express.raw() on the webhook route instead of express.json():

app.post('/hooks/verid', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyVeridSignature(req.get('Verid-Signature') ?? null, rawBody, process.env.VERID_SECRET!)) {
    return res.status(401).send('invalid signature');
  }
  const payload = JSON.parse(rawBody);
  // ... handle the change ...
  res.status(200).send('ok');
});

Next.js route handler: call await request.text() rather than request.json(), verify, then JSON.parse the text.

Flask: request.get_data(as_text=True) gives you the raw body; do not use request.json before verifying.

If you have a global JSON body parser in front of every route, it has usually already consumed the body by the time your webhook handler runs. Exclude the webhook route from it, or register the raw parser for that path first.

Responding, and what happens next

Return a 2xx status once you have accepted the delivery, and do it quickly. Verid waits 15 seconds for a response. If your endpoint needs longer (calling other services, heavy processing), accept the request, queue the work, and respond straight away.

What Verid does with your response:

  • 2xx - delivered.
  • 5xx, a timeout, or a connection error - treated as a temporary failure and retried later on a fixed schedule: 6 attempts in total, at 5 minutes, 15 minutes, 30 minutes, 1 hour, and 2 hours after each failure. After the last failed attempt the delivery is marked dead.
  • 4xx - treated as a final answer and not retried. That is the right outcome when you return 401 for a bad signature. It also means a 404 from a mistyped route or a 400 from a buggy handler will not be retried, so check your endpoint returns 2xx for genuine deliveries.

Because a retry resends the same delivery, your handler can see the same payload more than once. Make it idempotent: record the payload's run_id when you process it, and skip any run_id you have already handled. Each check has a unique run_id, so this is safe even when many monitors send to the same endpoint.

Sending your own headers with each delivery

Some endpoints expect their own credentials on top of the signature, such as an API gateway that wants an Authorization token, or a routing header for a shared ingest service. Add a headers object to the webhook delivery and Verid sends those headers on every attempt:

{
  "type": "webhook",
  "url": "https://your-app.example.com/hooks/verid",
  "headers": {
    "Authorization": "Bearer your-token",
    "X-Source": "verid"
  }
}

Rules for custom headers:

  • Up to 20 headers per webhook delivery. Header values must be printable ASCII (no line breaks).
  • Verid-Signature, Host, Content-Length, Connection, Transfer-Encoding, and Cookie are rejected when you save the monitor.
  • Content-Type and User-Agent are always Verid's own values; anything you set for them is ignored.
  • Headers are read from the monitor when each attempt is sent, matched by the webhook's URL. If you rotate a token by editing the monitor, pending retries use the new value. If you change the webhook's URL, deliveries already queued for the old URL still go to the old URL, and without the custom headers.

Custom headers are set through the API (the deliveries array when you create or update a monitor) or the Node SDK. The dashboard form keeps any headers you set this way when you edit the monitor there.

A custom header is not a substitute for verifying the signature. A token proves the caller knows the token; only the signature proves the body is the one Verid sent and has not been altered.

Checklist

Before you trust a webhook endpoint in production:

  1. The signing secret is stored outside your code and loaded at runtime.
  2. The handler reads the raw body and verifies it before parsing.
  3. The verifier checks the timestamp against a 300 second tolerance.
  4. Signatures are compared in constant time.
  5. Invalid requests get a 4xx; genuine ones get a 2xx within 15 seconds.
  6. Processing is idempotent on the payload's run_id.
  7. The receiver checks for visual or serp blocks rather than assuming diff holds the change.

What to read next

  • Notifications - every delivery channel Verid supports, including Slack, Discord, and email.
  • Webhooks reference - the terse version of this guide, with the verifier in five languages.

Ready to try it? Point Verid at your URL and get a signed alert on every change. 200 free runs a month, no credit card.

Try Verid for free

200 free runs a month, no credit card required.