LayerHopdocs

Guides

Webhooks

Get a signed HTTPS callback when an order is submitted, filled, refunded or fails — event types, payload, signature verification, retries and testing.

Open in ClaudeOpen in ChatGPT

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#

  1. Sign in to the dashboard, open Webhooks and add an HTTPS URL.
  2. Optionally pick the events you want. An endpoint with no events selected receives all of them.
  3. 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:

JSON
{
  "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:

  1. Read the raw request body. Do not parse and re-serialize the JSON first.
  2. Recompute the HMAC and compare it to v1 in constant time.
  3. Reject requests whose t is more than 5 minutes from your clock. This stops replays.

TypeScript#

With @layerhop/shared (it works in Node 20+, Deno, Bun and edge runtimes):

TypeScript
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:

TypeScript
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#

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 treat data.order.status as 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.test event, and the result appears in the deliveries list.
  • To test locally, expose your local server with a tunnel (for example cloudflared tunnel or ngrok) and register the https:// tunnel URL. Production only delivers to public HTTPS URLs.
  • If you self-host the worker with NODE_ENV not set to production, it can also deliver to http://localhost.

See Order tracking for how order statuses move.