How tinyRelay signs webhooks
Every webhook request tinyRelay sends is signed and delivered through Svix. Each request carries three headers:
The delivery id. Stable across retries of the same event, so deduplicate on it.
When the delivery was signed, as a Unix timestamp in seconds.
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.
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.
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.
// 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
tinyRelay suppresses repeats for this long before they leave us.
A missed event can be redelivered within this window.
The default in the manual examples above. Your choice; five minutes is reasonable.
- 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
The body was parsed before verifying, or the wrong secret is configured. Check the route skips JSON middleware and the secret matches this endpoint.
A proxy or framework layer is rewriting the body, or the server clock is off and the timestamp check rejects it.
Your code only checks the first signature. Accept the request if any listed v1 signature verifies.
Delivery is at least once. Deduplicate on svix-id.
A firewall is blocking them. Allow traffic from 3.229.12.181, and still verify every request.
FAQ
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
- Email webhooks and delivery events
- Email bounce codes
- Security at tinyRelay
- Send email with Node.js
- Webhook responses (docs)