Guides
Webhooks
Get a signed HTTPS callback when an order is submitted, filled, refunded or fails — event types, payload, signature verification, retries and testing.
LayerHop can notify your backend when an order changes state, so you do not have to poll GET /v1/orders. Webhooks are sent for orders built with an API key that belongs to your account.
Set up an endpoint#
- Sign in to the dashboard, open Webhooks and add an HTTPS URL.
- Optionally pick the events you want. An endpoint with no events selected receives all of them.
- Copy the signing secret (
whsec_…). It is used to sign every request to that endpoint. Keep it out of client-side code.
Endpoints must be public https:// URLs. Requests to private, loopback or link-local addresses are refused, and redirects are never followed. A 3xx response counts as a failed attempt.
Events#
| Type | When |
|---|---|
order.submitted |
LayerHop first sees the source transaction you reported with POST /v1/orders/{quoteId}/submitted (or /v1/orders/submit). |
order.filled |
The destination side completed. For same-chain swaps, this is when the transaction confirmed successfully. |
order.refunded |
The provider refunded the user on the source chain. |
order.failed |
The source transaction reverted, the provider reported a failure, or the order was not settled within 24 hours (statusDetail: "timeout"). |
webhook.test |
Sent from the dashboard's Send test button. |
Orders that are built but never submitted become expired after one hour. No webhook is sent for that.
Payload#
Every request is a POST with a JSON body:
{
"id": "evt_01hx…",
"type": "order.filled",
"createdAt": "2026-10-17T12:00:00.000Z",
"data": {
"order": {
"quoteId": "q_…",
"status": "filled",
"statusDetail": null,
"provider": "relay",
"channel": "api",
"partnerId": null,
"srcChainId": 8453,
"srcToken": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"dstChainId": 7565164,
"dstToken": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"sender": "0x…",
"recipient": "9xQe…",
"amount": "100000000",
"dstAmount": "99650000",
"ourFeeBps": 30,
"partnerFeeBps": 0,
"srcTxHash": "0x…",
"dstTxHash": "5Kz…",
"createdAt": "2026-10-17T11:58:10.000Z",
"updatedAt": "2026-10-17T12:00:00.000Z"
}
}
}Amounts are integer strings in base units. Use id to deduplicate: retries resend the same event id.
Headers:
| Header | Value |
|---|---|
LayerHop-Event |
The event type, e.g. order.filled |
LayerHop-Delivery |
The delivery id (whd_…). It is the same across retries of one delivery. |
LayerHop-Signature |
t=<unix seconds>,v1=<hex signature> |
Content-Type |
application/json |
Verify the signature#
v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with your endpoint secret. To verify a request:
- Read the raw request body. Do not parse and re-serialize the JSON first.
- Recompute the HMAC and compare it to
v1in constant time. - Reject requests whose
tis more than 5 minutes from your clock. This stops replays.
TypeScript#
With @layerhop/shared (it works in Node 20+, Deno, Bun and edge runtimes):
import { verifyWebhookSignature } from "@layerhop/shared";
app.post("/layerhop/webhook", async (c) => {
const raw = await c.req.text();
const ok = await verifyWebhookSignature(process.env.LAYERHOP_WEBHOOK_SECRET!, c.req.header("LayerHop-Signature"), raw);
if (!ok) return c.text("bad signature", 400);
const event = JSON.parse(raw);
// …handle event.type, deduplicate on event.id
return c.text("ok");
});Without the package, using only node:crypto:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, header: string | undefined, rawBody: string, toleranceSec = 300): boolean {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2) as [string, string]));
const t = Number(parts.t);
if (!parts.v1 || !Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const got = Buffer.from(parts.v1, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}Python#
import hashlib, hmac, time
def verify(secret: str, header: str | None, raw_body: bytes, tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask
# @app.post("/layerhop/webhook")
# def hook():
# if not verify(SECRET, request.headers.get("LayerHop-Signature"), request.get_data()):
# abort(400)
# event = request.get_json()Retries#
Respond with any 2xx status within 10 seconds to acknowledge a delivery. Do slow work asynchronously, after you respond.
Any other outcome is retried: a non-2xx status, a timeout, a connection error or a redirect. Retries happen after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours. After the last retry the delivery is marked failed.
- Deliveries can arrive more than once and out of order. Deduplicate on the event
id, and treatdata.order.statusas the source of truth. - Disabling an endpoint stops its pending retries.
- The dashboard keeps each delivery's status, the HTTP code and the first 500 characters of your response. You can replay any delivery from there.
Testing#
- In the dashboard, open the endpoint and press Send test. You receive a signed
webhook.testevent, and the result appears in the deliveries list. - To test locally, expose your local server with a tunnel (for example
cloudflared tunnelorngrok) and register thehttps://tunnel URL. Production only delivers to public HTTPS URLs. - If you self-host the worker with
NODE_ENVnot set toproduction, it can also deliver tohttp://localhost.
See Order tracking for how order statuses move.