easyway
Create account

Verify webhook signatures

easyway tells your server about payments with webhooks: a POST to your URL when an invoice is paid, underpaid or expired, or when a withdrawal is sent. Anyone who finds that URL can POST to it too, so before you mark an order as paid, check that the request really came from easyway. It takes about ten lines of code.

Every delivery carries these headers:

HeaderExampleMeaning
X-Webhook-Signaturet=1758470400,v1=5f1c…e2a9The timestamp and the signature; the only header you must check
X-Webhook-Timestamp1758470400Same value as t, in Unix seconds
X-Webhook-Eventinvoice.paidThe event type, also in the body as type
X-Webhook-Idwhd_…This delivery; the same on every retry of it

The signature is v1 = hex(HMAC-SHA256(secret, t + "." + raw_body)). The secret is the endpoint's signing secret from Dashboard → Developers (account webhooks, or the webhook of a single API key). Because the timestamp is inside the signed string, a captured request cannot be replayed later with a new time.

To verify a request:

StepWhy
1. Read the raw bodyThe signature covers the exact bytes sent. A parsed and re-serialised body differs in spacing or key order and never matches.
2. Split the headerTake t and v1 from t=…,v1=….
3. Recompute the HMACHMAC-SHA256 over t.raw_body with the endpoint's secret, as lower-case hex.
4. Compare in constant timeA normal string compare can leak, through timing, how many leading characters were right.
5. Reject old timestampsRefuse anything more than five minutes from your clock. Keep the server clock synced (NTP).

Code you can copy

These are the same samples as in the API docs. They answer 400 to a bad signature and 200 once the event is accepted.

Node.js (Express)

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.EASYWAY_WEBHOOK_SECRET; // from Dashboard → Developers → Webhooks

// The RAW body is what was signed: parse it yourself, don't let a JSON
// middleware re-serialise it first.
app.post("/webhooks/easyway", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.header("X-Webhook-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const expected = createHmac("sha256", SECRET).update(`${parts.t}.${req.body}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!fresh || expected.length !== parts.v1?.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) {
    return res.status(400).send("bad signature");
  }

  const event = JSON.parse(req.body);
  // Store event.id and ignore repeats: deliveries are retried until you answer 2xx.
  if (event.type === "invoice.paid") {
    const inv = event.data.invoice;
    // markOrderPaid(inv.order_id, inv.net_amount, inv.pay_asset);
  }
  res.sendStatus(200); // answer fast; do the heavy work afterwards
});

Next.js (App Router route handler)

Route handlers do not parse the body for you, so await req.text() gives the raw string. Use the Node.js runtime (the default), since node:crypto is not available on the Edge runtime.

// app/api/webhooks/easyway/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.EASYWAY_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const raw = await req.text(); // the RAW body, before any JSON.parse
  const sig = Object.fromEntries((req.headers.get("x-webhook-signature") ?? "").split(",").map((kv) => kv.split("=")));
  const expected = createHmac("sha256", SECRET).update(`${sig.t}.${raw}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  if (!fresh || expected.length !== sig.v1?.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))) {
    return new Response("bad signature", { status: 400 });
  }

  const event = JSON.parse(raw);
  // Store event.id and ignore repeats, then queue the real work.
  return new Response(null, { status: 200 });
}

PHP

Plain PHP reads the raw body from php://input. In Laravel use $request->getContent(), not $request->all().

<?php
$secret  = getenv("EASYWAY_WEBHOOK_SECRET");   // Dashboard → Developers → Webhooks
$raw     = file_get_contents("php://input");    // the RAW body is what was signed
$header  = $_SERVER["HTTP_X_WEBHOOK_SIGNATURE"] ?? "";
parse_str(str_replace(",", "&", $header), $sig); // ["t" => "…", "v1" => "…"]

$expected = hash_hmac("sha256", $sig["t"] . "." . $raw, $secret);
$fresh    = abs(time() - (int) $sig["t"]) < 300;
if (!$fresh || !hash_equals($expected, $sig["v1"] ?? "")) {
    http_response_code(400);
    exit("bad signature");
}

$event = json_decode($raw, true);
// Store $event["id"] and ignore repeats: deliveries are retried until you answer 2xx.
if ($event["type"] === "invoice.paid") {
    $inv = $event["data"]["invoice"];
    // markOrderPaid($inv["order_id"], $inv["net_amount"], $inv["pay_asset"]);
}
http_response_code(200);

Python (Flask)

request.get_data() returns the raw bytes. In Django use request.body, and exempt the view from CSRF checks, since the signature is what authenticates it.

import hmac, hashlib, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["EASYWAY_WEBHOOK_SECRET"]  # Dashboard → Developers → Webhooks

@app.post("/webhooks/easyway")
def easyway_webhook():
    raw = request.get_data()                      # the RAW body is what was signed
    sig = dict(kv.split("=") for kv in request.headers.get("X-Webhook-Signature", "").split(","))
    expected = hmac.new(SECRET.encode(), f"{sig.get('t')}.".encode() + raw, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(sig.get("t", 0))) < 300
    if not fresh or not hmac.compare_digest(expected, sig.get("v1", "")):
        abort(400)

    event = json.loads(raw)
    # Store event["id"] and ignore repeats: deliveries are retried until you answer 2xx.
    if event["type"] == "invoice.paid":
        inv = event["data"]["invoice"]
        # mark_order_paid(inv["order_id"], inv["net_amount"], inv["pay_asset"])
    return "", 200

Test it on your machine

You can sign a request yourself with openssl and send it to your local handler. A correct handler answers 200; change one character of the body or wait five minutes and it must answer 400.

SECRET='whsec_…'   # the endpoint's signing secret
t=$(date +%s)
body='{"id":"evt_local_1","type":"webhook.test","mode":"test","created":'$t',"data":{"message":"It works."}}'
sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

curl -i http://localhost:3000/webhooks/easyway \
  -H 'Content-Type: application/json' \
  -H "X-Webhook-Signature: t=$t,v1=$sig" \
  --data "$body"

To test with real deliveries, create a webhook in test mode and press Send test in the dashboard (it sends a webhook.test event), or create a test invoice and press Simulate payment to get the real invoice.paid flow. Your URL must be reachable from the internet; private and local addresses are refused in production.

Common mistakes

SymptomCause and fix
Signature never matchesA JSON middleware parsed the body first (for example express.json() on the route). Hash the raw body, then parse it.
Matches in test, fails in liveTest and live endpoints have different secrets. Use the secret of the endpoint that received the request.
Fails for one project onlyAn API key with its own webhook signs with that endpoint's secret, not the account webhook's.
Timestamp check failsYour server clock is off. Sync it; do not widen the window to hours.
Orders marked paid twiceA slow 200 can lead to a retry. Store event.id and skip events you have already handled.

Retries and timeouts

Answer with a 2xx within 10 seconds and do the heavy work afterwards; a timeout, a redirect or any other status counts as a failure. A failed delivery is tried up to 8 times in about 45 hours, after 1 minute, 5 minutes, 30 minutes, then 2, 6, 12 and 24 hours. Each attempt is signed again with the current time, so retries pass the five-minute check. After 25 failures in a row the endpoint is switched off; the dashboard lists every attempt with its status code and error and lets you retry one by hand.

Events can arrive out of order. Decide from data.invoice.status in the body, or fetch the invoice with GET /v1/invoices/:id when in doubt. The full list of events and fields is in the webhooks section of the docs and in the OpenAPI document.

Create a merchant accountRead the API docs
Verify crypto payment webhook signatures | easyway