DocsCore conceptsWebhooks

Webhooks

Verid signs all webhook deliveries using HMAC-SHA256. The signature is included in the Verid-Signature header.

Signature format

text
Verid-Signature: t={timestamp},v1={signature}

Where:

  • timestamp is a Unix timestamp (seconds)
  • signature = HMAC-SHA256({timestamp}.{raw_body}, webhook_secret)

You must verify the signature before processing any webhook payload.


Getting your signing secret

Each monitor gets its own secret, generated the first time you give it a webhook delivery. Read it from:

text
GET /v1/monitors/{id}/signing-secret
json
{ "secret": "3f9c…e21a" }

secret is null when the monitor has no webhook delivery configured, so there is nothing signed to verify yet. The secret is a 64-character hex string with no prefix. It is also shown on the monitor's page in the dashboard, in the Webhook verification card on the Config tab.

The value is not included in GET /v1/monitors or GET /v1/monitors/{id} - ask for it explicitly, store it in your own secret manager, and pass it as the secret argument in the examples below.


Verification examples

Node.js

typescript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhook(
  header: string,
  rawBody: string,
  secret: string,
  toleranceSecs = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=')),
  );
  const timestamp = parseInt(parts['t'] ?? '0', 10);
  const signature = parts['v1'];

  if (!timestamp || !signature) return false;

  // Check timestamp drift
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSecs) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

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

Python

python
import hmac
import hashlib
import time

def verify_webhook(header: str, raw_body: str, secret: str, tolerance_secs: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts.get("t", 0))
    signature = parts.get("v1", "")

    if not timestamp or not signature:
        return False

    if abs(time.time() - timestamp) > tolerance_secs:
        return False

    signed_payload = f"{timestamp}.{raw_body}"
    expected = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Ruby

ruby
require 'openssl'
require 'time'

def verify_webhook(header, raw_body, secret, tolerance_secs: 300)
  parts = Hash[header.split(',').map { |p| p.split('=', 2) }]
  timestamp = parts['t']&.to_i
  signature = parts['v1']

  return false unless timestamp && signature
  return false if (Time.now.to_i - timestamp).abs > tolerance_secs

  signed_payload = "#{timestamp}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)

  ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end

Go

go
package main

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

func verifyWebhook(header, rawBody, secret string, toleranceSecs float64) bool {
    parts := make(map[string]string)
    for _, p := range strings.Split(header, ",") {
        kv := strings.SplitN(p, "=", 2)
        if len(kv) == 2 {
            parts[kv[0]] = kv[1]
        }
    }

    tsStr, ok := parts["t"]
    if !ok { return false }
    ts, err := strconv.ParseInt(tsStr, 10, 64)
    if err != nil { return false }

    sig, ok := parts["v1"]
    if !ok { return false }

    if math.Abs(float64(time.Now().Unix()-ts)) > toleranceSecs { return false }

    signedPayload := fmt.Sprintf("%d.%s", ts, rawBody)
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(expected), []byte(sig))
}

PHP

php
<?php
function verifyWebhook(string $header, string $rawBody, string $secret, int $toleranceSecs = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k] = $v;
    }

    $timestamp = (int)($parts['t'] ?? 0);
    $signature = $parts['v1'] ?? '';

    if (!$timestamp || !$signature) return false;
    if (abs(time() - $timestamp) > $toleranceSecs) return false;

    $signedPayload = "{$timestamp}.{$rawBody}";
    $expected = hash_hmac('sha256', $signedPayload, $secret);

    return hash_equals($expected, $signature);
}
?>

Webhook payload structure

json
{
  "id": "del_01H...",
  "version": "2026-05-01",
  "monitor_id": "uuid",
  "run_id": "uuid",
  "fired_at": "2026-05-08T12:00:00Z",
  "diff": {
    "fields_changed": ["price", "stock"],
    "before": { "price": "49.99", "stock": "In stock" },
    "after":  { "price": "44.99", "stock": "Low stock" }
  },
  "monitor": {
    "url":  "https://example.com/product",
    "name": "My Product Monitor"
  }
}

The request also includes the header User-Agent: Verid/1.0.

To send your own headers with every delivery (for example an Authorization token your endpoint expects), add a headers object to the webhook delivery: { "type": "webhook", "url": "…", "headers": { "Authorization": "Bearer …" } }. Up to 20 headers. Verid-Signature, Host, Content-Length, Connection, Transfer-Encoding and Cookie are rejected; Content-Type and User-Agent are always Verid's own values.

Retries

If your endpoint returns a 5xx response, does not answer within 15 seconds, or cannot be reached, Verid retries on this schedule. A 4xx response is treated as a final answer and is not retried:

AttemptDelay
1Immediate
25 minutes
315 minutes
430 minutes
51 hour
62 hours

After 6 failed attempts, the delivery is marked as dead. You can replay dead deliveries from the dashboard or via POST /v1/deliveries/:id/replay.