Verify signatures
Anyone can send a POST to your webhook URL. Every request from Vaartix is signed with your
endpoint’s secret, so check the signature first and reject anything that doesn’t match.
Vaartix follows the open Standard Webhooks specification, so you can also use any Standard Webhooks library.
import express from 'express';import { verifyWebhook, WebhookVerificationError } from 'vaartix';
const app = express();
app.post('/webhooks/vaartix', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = verifyWebhook({ payload: req.body, // a Buffer, thanks to express.raw() headers: req.headers, secret: process.env.VAARTIX_WEBHOOK_SECRET!, }); } catch (err) { if (err instanceof WebhookVerificationError) return res.status(400).send('Invalid signature'); throw err; }
res.sendStatus(200); // Handle the event after responding, e.g. queue it. if (event.type === 'email.bounced') markAddressBad(event.data.recipient);});import { verifyWebhook } from 'vaartix';
export async function POST(request: Request) { const payload = await request.text(); try { const event = verifyWebhook({ payload, headers: request.headers, secret: process.env.VAARTIX_WEBHOOK_SECRET!, }); // ...handle event.type and event.data return new Response('ok'); } catch { return new Response('Invalid signature', { status: 400 }); }}With the Python SDK, this is one call:
verify_webhook(request.get_data(), request.headers, secret). Without it:
import base64, hashlib, hmac, json, os, time
SECRET = os.environ["VAARTIX_WEBHOOK_SECRET"] # whsec_...
def verify(body: bytes, headers) -> dict: msg_id = headers["webhook-id"] timestamp = headers["webhook-timestamp"] if abs(time.time() - int(timestamp)) > 300: raise ValueError("timestamp too old") key = base64.b64decode(SECRET.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in headers["webhook-signature"].split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(body) raise ValueError("invalid signature")
# Flask: event = verify(request.get_data(), request.headers)With the PHP SDK, this is one call:
Webhook::verify(file_get_contents('php://input'), getallheaders(), $secret), or in Laravel
Webhook::verify($request->getContent(), $request->headers->all(), $secret). Without it:
<?phpfunction verify_vaartix(string $body, array $headers, string $secret): array { $id = $headers['webhook-id']; $timestamp = $headers['webhook-timestamp']; if (abs(time() - (int) $timestamp) > 300) { throw new Exception('Timestamp too old'); } $key = base64_decode(substr($secret, strlen('whsec_'))); $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature']) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return json_decode($body, true); } } throw new Exception('Invalid signature');}
// Laravel: verify_vaartix($request->getContent(), array_map(fn ($v) => $v[0], $request->headers->all()), env('VAARTIX_WEBHOOK_SECRET'));How the signature works
Section titled “How the signature works”- Take the
webhook-idandwebhook-timestampheaders and the raw body. - Join them with dots:
{webhook-id}.{webhook-timestamp}.{body}. - Compute HMAC-SHA256 of that string. The key is your secret without the
whsec_prefix, base64-decoded. - Base64-encode the result and compare it, in constant time, with each
v1,entry in thewebhook-signatureheader (entries are separated by spaces). - Reject requests whose timestamp is more than 5 minutes from your clock, so an intercepted request can’t be replayed later.