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.
Your signing secret is per endpoint, is shown in the partner portal at partner.easy2257.com/webhooks, and is prefixed whsec_. Two endpoints never share a secret.
Headers on every delivery
| Header | Value |
|---|---|
X-EZ2257-Signature | t=1714000000,v1=abc123... |
X-EZ2257-Event | The event type, e.g. solo_account.verified. Route on this before parsing the body. |
X-EZ2257-Delivery | An opaque id for this delivery, prefixed del_. Portal test pings send the literal del_test. |
Signature format
X-EZ2257-Signature: t=1714000000,v1=abc123...t: Unix timestamp in seconds, generated at the moment the request is sentv1: HMAC-SHA256 of{t}.{rawBody}, hex-encoded, keyed with your endpoint'swhsec_secret
Verification algorithm
- Extract
tandv1from the header - Construct the signed payload:
"{t}.{rawBody}" - Compute HMAC-SHA256 of that payload with your endpoint secret, hex-encoded
- 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 requests that have been sitting around. The default tolerance in the snippets above is 5 minutes. Tighten it to 60 seconds in production if your clock is reliable.
Freshness is not deduplication. Each retry is re-signed at send time with a new t, so a delivery that first failed yesterday still arrives with a current timestamp and passes any tolerance you set. The event id stays the same across every attempt, so that is the only safe dedupe key.
Deduplicate on event.id. Ids look like evt_m2k9x1_a7f3qz (portal test pings look like evt_test_m2k9x1). Treat them as opaque strings: do not parse a timestamp out of them.