Skip to Content
LandedFXDocs

Webhooks

Get a signed POST when money moves in a vault or the naira risk level changes, instead of polling.

Add an endpoint under Integrations in the dashboard, choose your events, and copy the signing secret. It is shown once. Use Send test to see a delivery straight away.

Events

EventWhen it firesdata
vault.depositA deposit is indexed from the chainvaultId, wallet, assets, asset, shares, txHash, blockNumber
vault.withdrawA withdrawal is indexed from the chainThe same fields
risk.level_changedThe naira risk watchdog moves between calm, watch and step_asidecurrency, level, previousLevel, reasons
webhook.testYou press Send testmessage

An endpoint receives events from after it was created. It never receives a backfill of older ones.

What you receive

POST /landedfx HTTP/1.1
Content-Type: application/json
User-Agent: LandedFX-Webhooks/1.0
LandedFX-Signature: t=1794000000,v1=5f8a...
LandedFX-Event-Id: deposit_0xabc..._12
LandedFX-Event-Type: vault.deposit

{
  "id": "deposit_0xabc..._12",
  "type": "vault.deposit",
  "createdAt": "2026-11-09T21:30:00.000Z",
  "data": {
    "vaultId": "dollar",
    "wallet": "0x...",
    "assets": 1000,
    "asset": "USDC",
    "shares": "1000000000000",
    "txHash": "0xabc...",
    "blockNumber": 54700000
  }
}

Verify the signature

Anyone can POST to a public URL, so check every request. The signature is an HMAC-SHA256 of <t>.<raw body> using your secret. Use the raw bytes you received: parsing and re-serialising the JSON can change them.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function isFromLandedFX(rawBody: string, header: string, secret: string, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; // too old: possibly a replay

  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}
import hashlib, hmac, time

def is_from_landedfx(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    if "t" not in parts or "v1" not in parts or not parts["t"].isdigit():
        return False
    if abs(time.time() - int(parts["t"])) > tolerance_seconds:
        return False  # too old: possibly a replay
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Respond, retry and duplicates

  • Return any 2xx within 10 seconds. Do the slow work after you respond. Redirects are not followed and count as a failure.
  • Failures retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, about 21 hours in all. After that the delivery is marked failed.
  • Handle duplicates. Delivery is at least once, so store id and ignore one you have already handled.
  • Don't rely on order. Use createdAt, and blockNumber for vault events.

The dashboard shows each endpoint's recent deliveries, and the log is kept for 30 days.

Endpoint rules

URLs must use https on the standard port and resolve to a public address. Each account can have 5 endpoints. Rotate replaces the secret and the old one stops working at once, so have the new one ready to deploy.

On this page