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.


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.