Webhook Signatures
How to verify Easy2257 webhook signatures. Raw body is required — not re-parsed JSON.
Every webhook request from Easy2257 includes an X-EZ2257-Signature header.
Always verify signatures before processing events. An unverified webhook endpoint is an open door for spoofed events.
Signature format
X-EZ2257-Signature: t=1714000000,v1=abc123...t— Unix timestamp (seconds) when Easy2257 sent the requestv1— HMAC-SHA256 of{t}.{rawBody}using your webhook secret
Verification algorithm
- Extract
tandv1from the header - Construct the signed payload:
"{t}.{rawBody}" - Compute HMAC-SHA256 of the payload using your webhook secret
- Compare your computed signature to
v1using a constant-time comparison - Optionally: reject if
tis more than 5 minutes ago (replay protection)
Use the raw request body for the HMAC — the literal bytes received over the wire. Never JSON.parse the body and re-serialize it before hashing. Re-serialization changes whitespace and key order, producing a different hash. This is the single most common integration failure.
Verify function + webhook handler
import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const { t, v1 } = parts;
if (!t || !v1) return false;
if (toleranceSeconds > 0 && Math.floor(Date.now() / 1000) - parseInt(t) > toleranceSeconds)
return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Next.js App Router — app/api/webhooks/ez2257/route.ts
export async function POST(req) {
const rawBody = await req.text(); // NEVER req.json() before this
const sig = req.headers.get('x-ez2257-signature') ?? '';
if (!verifyWebhookSignature(rawBody, sig, process.env.EZ2257_WEBHOOK_SECRET))
return new Response('Unauthorized', { status: 401 });
const event = JSON.parse(rawBody);
// handle event...
return new Response('ok');
}
// Express — use express.raw(), not express.json(), on this route
app.post('/webhooks/ez2257', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const sig = req.headers['x-ez2257-signature'] ?? '';
if (!verifyWebhookSignature(rawBody, sig, process.env.EZ2257_WEBHOOK_SECRET))
return res.status(401).send('Unauthorized');
const event = JSON.parse(rawBody);
// handle event...
res.send('ok');
});import hmac, hashlib, time
def verify_webhook_signature(raw_body, header, secret, tolerance_seconds=300):
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
t, v1 = parts.get('t'), parts.get('v1')
if not t or not v1:
return False
if tolerance_seconds > 0 and int(time.time()) - int(t) > tolerance_seconds:
return False
body_bytes = raw_body if isinstance(raw_body, bytes) else raw_body.encode('utf-8')
payload = f'{t}.'.encode() + body_bytes
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(v1, expected)
# Flask — use request.get_data(), not request.get_json()
from flask import Flask, request, abort
import os, json
app = Flask(__name__)
@app.post('/webhooks/ez2257')
def ez2257_webhook():
raw_body = request.get_data() # bytes
sig = request.headers.get('X-EZ2257-Signature', '')
if not verify_webhook_signature(raw_body, sig, os.environ['EZ2257_WEBHOOK_SECRET']):
abort(401)
event = json.loads(raw_body)
# handle event...
return 'ok'
# FastAPI — use await request.body(), not request.json()
from fastapi import FastAPI, Request, HTTPException
fastapi_app = FastAPI()
@fastapi_app.post('/webhooks/ez2257')
async def ez2257_webhook_fastapi(request: Request):
raw_body = await request.body()
sig = request.headers.get('x-ez2257-signature', '')
if not verify_webhook_signature(raw_body, sig, os.environ['EZ2257_WEBHOOK_SECRET']):
raise HTTPException(status_code=401)
event = json.loads(raw_body)
# handle event...
return {'ok': True}function verifyWebhookSignature(
string $rawBody,
string $header,
string $secret,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(',', $header) as $pair) {
$kv = explode('=', $pair, 2);
if (count($kv) === 2) $parts[$kv[0]] = $kv[1];
}
$t = $parts['t'] ?? null;
$v1 = $parts['v1'] ?? null;
if (!$t || !$v1) return false;
if ($toleranceSeconds > 0 && time() - (int) $t > $toleranceSeconds) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $v1);
}
// Plain PHP — use php://input
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_EZ2257_SIGNATURE'] ?? '';
if (!verifyWebhookSignature($rawBody, $sig, getenv('EZ2257_WEBHOOK_SECRET'))) {
http_response_code(401); exit('Unauthorized');
}
$event = json_decode($rawBody, true);
// handle event...
echo 'ok';
// Laravel — use $request->getContent(), exempt route from VerifyCsrfToken
Route::post('/webhooks/ez2257', function (Illuminate\Http\Request $request) {
$rawBody = $request->getContent();
$sig = $request->header('X-EZ2257-Signature', '');
if (!verifyWebhookSignature($rawBody, $sig, env('EZ2257_WEBHOOK_SECRET')))
abort(401);
$event = json_decode($rawBody, true);
// handle event...
return response('ok');
});using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhookSignature(
string rawBody, string header, string secret, int toleranceSeconds = 300)
{
var parts = header.Split(',')
.Select(p => p.Split('=', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1))
return false;
if (toleranceSeconds > 0 &&
DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t) > toleranceSeconds)
return false;
var payload = Encoding.UTF8.GetBytes($"{t}.{rawBody}");
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(payload)).ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(v1), Encoding.UTF8.GetBytes(expected));
}
// ASP.NET Core controller — call EnableBuffering() before reading body
[HttpPost("/webhooks/ez2257")]
public async Task<IActionResult> Ez2257Webhook()
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
var sig = Request.Headers["X-EZ2257-Signature"].FirstOrDefault() ?? "";
if (!WebhookVerifier.VerifyWebhookSignature(rawBody, sig, _webhookSecret))
return Unauthorized();
var evt = JsonSerializer.Deserialize<Ez2257Event>(rawBody);
// handle event...
return Ok();
}Replay protection
The t timestamp lets you reject old events. The default tolerance in the snippets above is 5 minutes — tighten to 60 seconds in production if your clock is reliable.
Deduplicate on event.id — Easy2257 retries failed deliveries and the same event may arrive more than once.