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
| Event | When it fires | data |
|---|---|---|
vault.deposit | A deposit is indexed from the chain | vaultId, wallet, assets, asset, shares, txHash, blockNumber |
vault.withdraw | A withdrawal is indexed from the chain | The same fields |
risk.level_changed | The naira risk watchdog moves between calm, watch and step_aside | currency, level, previousLevel, reasons |
webhook.test | You press Send test | message |
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
idand ignore one you have already handled. - Don't rely on order. Use
createdAt, andblockNumberfor 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.