Easy2257
Webhooks

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

HeaderValue
X-EZ2257-Signaturet=1714000000,v1=abc123...
X-EZ2257-EventThe event type, e.g. solo_account.verified. Route on this before parsing the body.
X-EZ2257-DeliveryAn 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 sent
  • v1: HMAC-SHA256 of {t}.{rawBody}, hex-encoded, keyed with your endpoint's whsec_ secret

Verification algorithm

  1. Extract t and v1 from the header
  2. Construct the signed payload: "{t}.{rawBody}"
  3. Compute HMAC-SHA256 of that payload with your endpoint secret, hex-encoded
  4. Compare your computed signature to v1 using a constant-time comparison
  5. Optionally: reject if t is 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.

On this page