Webhooks

Webhooks

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

Signature format

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:

GET /v1/monitors/{id}/signing-secret
{ "secret": "whsec_…" }

secret is null when the monitor has no webhook delivery configured, so there is nothing signed to verify yet. The secret is also shown on the monitor's page in the dashboard, under the webhook delivery.

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

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
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'));
}

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

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

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
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

{
  "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.

Retries

If your endpoint returns a non-2xx response or times out, Verid retries with exponential backoff:

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.