Guide · Webhooks · Svix

Verify webhook signatures

To verify a tinyRelay webhook, compute an HMAC-SHA256 of svix-id.svix-timestamp.body with your endpoint secret and compare it, in constant time, with the svix-signature header. The official Svix libraries do this for you in one call.

svix-signature: v1,K5o3n1m...
{"eventType":"DELIVERED","receiver":"ann@example.com",...}
1
raw body · exact bytes, before JSON parsing
2
svix-timestamp · within your tolerance
3
HMAC-SHA256 · over svix-id.svix-timestamp.body
4
svix-signature · a v1 signature matches, constant time
Not checked yetVerify request
svix-signature: v1,K5o3n1m...
{"eventType":"DELIVERED","receiver":"eve@example.com",...}
1
raw body · exact bytes, before JSON parsing
2
svix-timestamp · within your tolerance
3
HMAC-SHA256 · computed over the changed body
4
svix-signature · no v1 signature matches
Rejected. Return 400.body changed in transit

Illustrative walk-through; no request is sent. Values shortened.

How tinyRelay signs webhooks

Every webhook request tinyRelay sends is signed and delivered through Svix. Each request carries three headers:

svix-id

The delivery id. Stable across retries of the same event, so deduplicate on it.

svix-timestamp

When the delivery was signed, as a Unix timestamp in seconds.

svix-signature

One or more space-separated signatures, each written as v1,<base64>.

The signature is an HMAC-SHA256 over {svix-id}.{svix-timestamp}.{body}, where body is the raw request body exactly as received, keyed with your endpoint's signing secret.

A signed webhook request · illustrativeCopy
POST /webhooks/tinyrelay HTTP/1.1
Content-Type: application/json
svix-id: msg_2b7Qx...
svix-timestamp: 1730390400
svix-signature: v1,K5o3n1m... v1,9fZt0aQ...

{"eventType":"DELIVERED","receiver":"ann@example.com","requestId":"833c5151-...","metadata":{"order_id":"SO-1001"}, ...}

Before you start

  • A webhook endpoint registered with tinyRelay. You give our team a public URL that accepts POST during onboarding.
  • Your endpoint signing secret. Each endpoint has its own secret, provided when your relay endpoint is configured. Store it with your other secrets; to rotate it, contact support.
  • Access to the raw request body in your framework, before JSON parsing.
Svix signing secrets are written as whsec_ followed by a base64 key; the HMAC key is the base64-decoded part after the prefix. The manual examples below strip the prefix if it is there.

Verify webhook signatures, step by step

1. Get your endpoint signing secret

Put the secret in an environment variable such as TINYRELAY_WEBHOOK_SECRET. Never ship it in client-side code or commit it.

2. Keep the raw request body

The signature covers the exact bytes sent. A framework that parses JSON and re-serializes it will break verification, so read the raw body on this route.

3. Verify the svix headers

Recommended: the official Svix libraries. They check the timestamp, the signature and secret rotation in one call and throw if anything fails.

// server.mjs  (npm install express svix)
import express from "express";
import { Webhook } from "svix";

const app = express();
const wh = new Webhook(process.env.TINYRELAY_WEBHOOK_SECRET);

// Keep the raw body: do not run express.json() on this route
app.post("/webhooks/tinyrelay", express.raw({ type: "application/json" }), (req, res) => {
  try {
    wh.verify(req.body, {
      "svix-id": req.header("svix-id"),
      "svix-timestamp": req.header("svix-timestamp"),
      "svix-signature": req.header("svix-signature"),
    });
  } catch (err) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // event.eventType, event.receiver, event.metadata, ...
  res.sendStatus(204);
});

app.listen(3000);
# app.py  (pip install flask svix)
import json
import os

from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["TINYRELAY_WEBHOOK_SECRET"])


@app.post("/webhooks/tinyrelay")
def tinyrelay_webhook():
    payload = request.get_data()  # raw bytes, not request.json
    try:
        wh.verify(payload, request.headers)
    except WebhookVerificationError:
        return "", 400

    event = json.loads(payload)
    # event["eventType"], event["receiver"], event["metadata"], ...
    return "", 204
<?php
// webhook.php  (composer require svix/svix)
require __DIR__ . '/vendor/autoload.php';

use Svix\Webhook;
use Svix\Exception\WebhookVerificationException;

$payload = file_get_contents('php://input');
$headers = [
    'svix-id'        => $_SERVER['HTTP_SVIX_ID'] ?? '',
    'svix-timestamp' => $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '',
    'svix-signature' => $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '',
];

try {
    $wh = new Webhook(getenv('TINYRELAY_WEBHOOK_SECRET'));
    $wh->verify($payload, $headers);
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);
// $event['eventType'], $event['receiver'], $event['metadata'], ...
http_response_code(204);

Svix signature verification by hand. Verifying manually is supported; the scheme above is all of it. Each function returns true only if the timestamp is fresh and a v1 signature matches.

Manual HMAC-SHA256
// verify.mjs  (no dependencies)
import crypto from "node:crypto";

export function verifyTinyRelay(rawBody, headers, secret, toleranceSeconds = 300) {
  const id = headers["svix-id"];
  const timestamp = headers["svix-timestamp"];
  const signatures = headers["svix-signature"];
  if (!id || !timestamp || !signatures) return false;

  // 1. Reject old or future-dated requests
  const ts = Number(timestamp);
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isInteger(ts) || Math.abs(now - ts) > toleranceSeconds) return false;

  // 2. HMAC-SHA256 over "{svix-id}.{svix-timestamp}.{raw body}"
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  // 3. Any listed v1 signature may match (secret rotation sends two)
  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const given = Buffer.from(signature, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
# verify.py  (standard library only)
import base64
import hashlib
import hmac
import time


def verify_tinyrelay(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
    msg_id = headers.get("svix-id")
    timestamp = headers.get("svix-timestamp")
    signatures = headers.get("svix-signature")
    if not (msg_id and timestamp and signatures):
        return False

    # 1. Reject old or future-dated requests
    try:
        if abs(time.time() - int(timestamp)) > tolerance:
            return False
    except ValueError:
        return False

    # 2. HMAC-SHA256 over "{svix-id}.{svix-timestamp}.{raw body}"
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # 3. Any listed v1 signature may match (secret rotation sends two)
    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
<?php
// verify.php  (no dependencies)
function verify_tinyrelay(string $rawBody, string $id, string $timestamp, string $signatures, string $secret, int $tolerance = 300): bool
{
    if ($id === '' || $signatures === '' || !ctype_digit($timestamp)) {
        return false;
    }

    // 1. Reject old or future-dated requests
    if (abs(time() - (int) $timestamp) > $tolerance) {
        return false;
    }

    // 2. HMAC-SHA256 over "{svix-id}.{svix-timestamp}.{raw body}"
    $key = base64_decode(preg_replace('/^whsec_/', '', $secret));
    $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));

    // 3. Any listed v1 signature may match (secret rotation sends two)
    foreach (explode(' ', $signatures) as $entry) {
        [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

$ok = verify_tinyrelay(
    file_get_contents('php://input'),
    $_SERVER['HTTP_SVIX_ID'] ?? '',
    $_SERVER['HTTP_SVIX_TIMESTAMP'] ?? '',
    $_SERVER['HTTP_SVIX_SIGNATURE'] ?? '',
    getenv('TINYRELAY_WEBHOOK_SECRET')
);
http_response_code($ok ? 204 : 400);

4. Deduplicate and respond

24hours
Duplicate suppression

tinyRelay suppresses repeats for this long before they leave us.

After thatdedupe on svix-id
5days
Payloads kept for replay

A missed event can be redelivered within this window.

Deliveryat least once
300seconds
Timestamp tolerance

The default in the manual examples above. Your choice; five minutes is reasonable.

Too oldreject
  • Delivery is at least once. If your endpoint times out or returns a 5xx, the event is retried with the same svix-id; record processed ids and skip repeats.
  • tinyRelay suppresses repeats for 24 hours before they leave us. Beyond that, a re-processed event could reach you again, so keep your own record of ids for longer.
  • A second open or click by the same recipient is a genuine new event with its own id. It is not a duplicate.
  • Event payloads are kept for 5 days, so a missed event can be redelivered within that window.
  • Return a 2xx quickly and do slow work in a background job, so the request does not time out and get retried.

What you get once it verifies

The payload carries eventType (SEND, DELIVERED, BOUNCED, OPEN, CLICK, UNSUBSCRIBE or SPAM), receiver, requestId, campaignId, your merged metadata, requestTime (epoch milliseconds as a string) and eventTime (epoch milliseconds, or null when unknown). BOUNCED adds bounceCategory; CLICK adds url. See email webhooks and delivery events and the email bounce codes reference.

Troubleshooting

What do you see?5 common symptoms
Every request failsLikely cause

The body was parsed before verifying, or the wrong secret is configured. Check the route skips JSON middleware and the secret matches this endpoint.

Fails only in productionLikely cause

A proxy or framework layer is rewriting the body, or the server clock is off and the timestamp check rejects it.

Fails after a secret rotationLikely cause

Your code only checks the first signature. Accept the request if any listed v1 signature verifies.

The same event is processed twiceLikely cause

Delivery is at least once. Deduplicate on svix-id.

No requests arriveLikely cause

A firewall is blocking them. Allow traffic from 3.229.12.181, and still verify every request.

FAQ

Is allowlisting the webhook IP enough to trust a request?

No. tinyRelay sends webhooks from 3.229.12.181, but allowlisting an IP is not authentication. Anything that can reach your endpoint can post to it, so verify the signature on every request.

An HMAC-SHA256 over the svix-id, the svix-timestamp and the raw request body joined with full stops, keyed with your endpoint's signing secret. The result is base64-encoded and sent in svix-signature as v1,<signature>.

Usually because the body was parsed and re-serialized before verifying. Verify the raw body exactly as received, before any JSON middleware touches it.

During a secret rotation both the old and the new secret sign the request. Treat the request as authentic if any listed signature verifies.

Delivery is at least once and retries repeat the same svix-id, so record processed svix-id values and skip repeats. tinyRelay suppresses repeats for 24 hours; size your own store beyond that.

Next steps

Sources

Get started

Send transactional email with a service that reports back.

SMTP relay or REST API, standalone or alongside tinyEmail. Tell us what you send and our team sets you up.