Receive signed HTTP events for selected Boros wallets and actions.
Wallet webhooks are in a private beta. Registration only accepts approved callback hosts, and the payload can change before v1 is frozen.
Delivery is retried but not guaranteed to be complete or timely, so do not drive trading or risk controls from it. Example receivers are in the receivers repository.
A webhook needs an HTTPS address that answers a challenge. The quickest one is the print receiver: a single file on Cloudflare Workers that answers the challenge and prints every event it receives. Save this as print-receiver.js:
// The smallest receiver that works: it answers the verification challenge and
// prints every event. One file, no storage, for trying webhooks out. Deploy it
// as a Cloudflare Worker (see README.md) and watch `wrangler tail`.
//
// A receiver you rely on should do more: keep the IDs it verified, deduplicate
// retries and refuse unsigned requests. That is src/receiver.ts.
//
// Settings, both optional:
// HOOK_PATH answer only on https://<host>/<HOOK_PATH>, so strangers cannot use this URL
// SIGNING_SECRETS {"sub_…":"whsec_…"}; a subscription listed here must be signed correctly,
// one that is not listed is printed without checking its signature
const encoder = new TextEncoder();
const hexBytes = (hex) => Uint8Array.from(hex.match(/../g), (byte) => Number.parseInt(byte, 16));
/** x-boros-signature is `t=<unix seconds>,v1=<hex>[,v1=<hex>]`; each v1 is HMAC-SHA256 of `<t>.<raw body>`. */
async function signatureVerifies(header, rawBody, secrets) {
const t = /(?:^|,)t=(\d+)(?:,|$)/.exec(header ?? "")?.[1];
const signatures = [...(header ?? "").matchAll(/(?:^|,)v1=([0-9a-f]{64})(?=,|$)/g)].map((match) => match[1]);
if (!t || signatures.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const message = encoder.encode(`${t}.${rawBody}`);
for (const secret of secrets) {
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
for (const signature of signatures) {
if (await crypto.subtle.verify("HMAC", key, hexBytes(signature), message)) return true;
}
}
return false;
}
const receiver = {
async fetch(request, env) {
if (env.HOOK_PATH && new URL(request.url).pathname !== `/${env.HOOK_PATH}`) return new Response("Not found", { status: 404 });
if (request.method !== "POST") return new Response("POST only", { status: 405 });
const rawBody = await request.text(); // the signature covers these exact characters, so read before parsing
let body;
try {
body = JSON.parse(rawBody);
} catch {
return new Response("Not JSON", { status: 400 });
}
const id = body.subscription_id ?? request.headers.get("x-boros-subscription-id");
const installed = JSON.parse(env.SIGNING_SECRETS || "{}")[id];
let signature = "signature not checked";
if (installed) {
if (!(await signatureVerifies(request.headers.get("x-boros-signature"), rawBody, [installed].flat()))) {
console.log(`REJECTED ${id}: bad or stale signature`);
return new Response("Bad signature", { status: 401 });
}
signature = "signature ok";
}
if (body.type === "boros.webhook.verification") {
console.log(`challenge for ${id} (${signature})`);
return new Response(body.challenge, { headers: { "content-type": "text/plain" } }); // the whole body, nothing else
}
console.log(`${body.type} for ${id}, attempt ${request.headers.get("x-boros-attempt") ?? "?"} (${signature})\n${JSON.stringify(body, null, 2)}`);
return new Response("ok");
},
};
export default receiver;
Deploy it to your own Cloudflare account. The fourth line prints your callback URL, and the last one shows events as they arrive:
HOOK_PATH=$(openssl rand -hex 16)
WORKER_URL=$(npx wrangler deploy print-receiver.js --name boros-webhook-print --compatibility-date 2026-08-01 | tee /dev/stderr | grep -o 'https://[^ ]*\.workers\.dev' | head -1)
printf '%s' "$HOOK_PATH" | npx wrangler secret put HOOK_PATH --name boros-webhook-print
echo "Callback URL: $WORKER_URL/$HOOK_PATH"
npx wrangler tail boros-webhook-printThe Worker's hostname has to be approved for the beta: get in touch to have it added. The print receiver is for trying things out: it keeps nothing, and it checks signatures only for subscriptions whose secret you give it. To build something you rely on, start from the full receiver in the receivers repository, which runs on Cloudflare Workers or Node, stores what it has seen and refuses unsigned requests.
This is the simpler route. Use the API below only if you are registering from a script.
registration.json.My Desk keeps the management token in your browser and shows the webhook's status beside the alert, with Send test, Manage and Stop delivery. Editing the alert updates the webhook, and the status line says so when an update did not go through and offers it again. Removing the alert deletes the subscription first; if it cannot be deleted, you can remove the alert alone after copying its management link. Anything that can read this site's storage in your browser can read the token, and clearing that storage loses it, so keep the copy you saved. The management page opens any subscription from its ID and token; a management link is read once and then removed from the address bar, so reloading the page asks for the link again. A new signing secret stays available in that browser tab, beside the alert and on the management page, until you mark it as saved, rotate it again or delete the webhook; it is never stored, so closing or reloading the tab loses it.
Webhooks follow named wallets and event types for now. An alert scoped to an asset, venue, market, side or size, or to any wallet, cannot be delivered yet.
POST /:id/verify. The subscription becomes active when your receiver answers the challenge.The base URL is https://borosexplorer.com/api/webhooks/subscriptions. Registration needs no authorization. This body subscribes one wallet to fills:
{
"callbackUrl": "https://receiver.example.com/hook/your-private-path",
"wallet": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"events": [
"fills"
]
}Keep the request and response in private files (mode 0600) so credentials never reach your terminal or shell history:
# Use a new output filename for each registration.
(umask 077; curl --silent --show-error --fail-with-body \
--max-time 20 --request POST \
--header 'content-type: application/json' \
--data-binary @/private/path/request.json \
--output /private/path/registration.json \
https://borosexplorer.com/api/webhooks/subscriptions)A 201 response contains id, status, managementToken, signingSecret, expiresAt and verification. If the request times out or the response is lost, do not register again; see lost credentials.
Registration sends the first challenge straight away, before your receiver can have the secret. The example receivers answer it with 503, so the subscription stays pending_verification. That uses one of the five challenge attempts, leaving four for after you install the secret.
Every other call takes the management token as a bearer header. Keep it in a private header file (Authorization: Bearer bwm_…), never in a URL or a log:
curl --silent --show-error --fail-with-body --max-time 20 \
--request POST --header @/private/path/management.headers \
--header 'content-type: application/json' --data '{}' \
https://borosexplorer.com/api/webhooks/subscriptions/sub_aaaaaaaaaaaaaaaaaaaa/verify{
"type": "boros.webhook.verification",
"version": 1,
"subscription_id": "sub_aaaaaaaaaaaaaaaaaaaa",
"challenge": "example-challenge"
}Check its signature as you would a delivery and store the subscription_id. Then reply 2xx within five seconds with the challenge value as the entire body: plain text, not JSON, at most 1,024 bytes, surrounding whitespace ignored. A 404 or 410 fails the challenge without marking the subscription gone.
A subscription follows one to eight wallet roots, including all of their subaccounts. An event matches when the wallet, the action and the wallet's role in it all match. The events shorthand covers:
| events | Actions |
|---|---|
| fills | Resting-order fills (full or partial), taker fills, OTC and AMM swaps. |
| orders | Placements, cancels and protocol cancels. |
| liquidations | Liquidated and liquidator, including force-deleverage on both sides. |
| settlements | Nonzero settlement payments. These are paid when the wallet next acts, one for each market it is in, so a single order can bring several. |
| collateral | Deposits, withdrawals and margin moves. |
For several wallets or specific actions, send filter instead of wallet and events:
{
"callbackUrl": "https://receiver.example.com/hook/your-private-path",
"filter": {
"actions": [
"maker-fill",
"taker-fill",
"swap"
],
"wallets": {
"mode": "specific",
"roots": [
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
]
},
"assets": [],
"venues": [],
"marketIds": [],
"collateral": [],
"side": "any",
"size": null
}
}Actions are maker-fill, taker-fill, swap, order-placed, order-cancelled, order-removed, liquidated, liquidator, settlement, deposit, withdrawal, cash-transfer, market-membership and agent-change. The other filter fields must stay as shown: wallets.mode is specific, the asset, venue, market and collateral lists are empty, side is any and size is null.
Callbacks must be HTTPS on port 443, on a public hostname from the beta allow-list, with no IP address, credentials or fragment. Redirects are never followed.
| Header | Value |
|---|---|
| content-type | application/json |
| x-boros-event-id | The envelope id. |
| x-boros-subscription-id | The envelope subscription_id. |
| x-boros-attempt | Delivery attempt, starting at 1. |
| user-agent | BorosExplorer-Webhooks/1 (+https://borosexplorer.com/docs/developers/webhooks) |
| x-boros-signature | t=<Unix seconds>,v1=<64 hex characters>[,v1=<64 hex characters>] |
The signature is an HMAC-SHA256 of the timestamp, a period and the raw request body. The key is the UTF-8 bytes of the whole whsec_… string; do not base64-decode it.
HMAC-SHA256(UTF8(signingSecret), UTF8(timestamp + ".") || rawBodyBytes)Verify against the bytes as received, before parsing: re-serialised JSON will not match. Compare in constant time and reject timestamps more than five minutes from your clock. Then check that the two ID headers match the envelope. The example receivers share a verifier, src/verify.ts, tested against the sender's signing vectors.
For 24 hours after a secret rotation the header carries two v1 signatures, new secret first. Accept either, then remove the old secret from your receiver.
A taker fill, with illustrative IDs and hashes:
{
"id": "42161:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:17:0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:taker-fill:-",
"type": "boros.wallet_event",
"version": 1,
"subscription_id": "sub_aaaaaaaaaaaaaaaaaaaa",
"matched": {
"actions": [
"taker-fill"
]
},
"origin": "live",
"times": {
"block": 1790266862,
"received_ms": 1790266864120,
"committed_ms": 1790266864410,
"sent_ms": 1790266864902
},
"event": {
"alertId": "42161:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:17:0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:taker-fill:-",
"chainId": 42161,
"walletRoot": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"role": "taker-fill",
"category": "trade",
"eventName": "MarketOrdersFilled",
"marketId": 197,
"marketSymbol": "BINANCE-ETHUSDT-30OCT2026",
"quantity": {
"raw": "1000000000000000000",
"decimals": 18,
"unit": "yu"
},
"side": "long-yu",
"rate": {
"source": "execution",
"aprLow": 0.0713,
"aprHigh": 0.0713
},
"orderId": null,
"accountId": 7,
"tokenId": 2,
"blockNumber": 508495079,
"blockHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"logIndex": 17,
"transactionHash": "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"timestamp": 1790266862,
"timestampIso": "2026-09-24T16:21:02.000Z",
"details": {},
"links": {
"explorerTx": "https://borosexplorer.com/tx/0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"explorerWallet": "https://borosexplorer.com/wallet/0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"explorerMarket": "https://borosexplorer.com/market/197",
"arbiscanTx": "https://arbiscan.io/tx/0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
},
"text": "taker-fill · BINANCE-ETHUSDT-30OCT2026 · long 1 YU at 7.13%"
}
}id is built from the chain, block hash, log index, wallet, role and qualifier. It is the same on every retry. One transaction can produce several events, so deduplicate on (subscription_id, id), not the transaction hash.times.block and event.timestamp are Unix seconds. The other times fields are milliseconds, and received_ms can be null.rate is a range of APRs as fractions, so 0.0713 is 7.13%. aprLow and aprHigh are equal unless the event covers orders at several rates. source is order for an order's limit rate and execution for the rate a trade happened at.orderId is a decimal string, because order IDs are too large for a JavaScript number. details carries extra fields for the event type, all as strings.null: a deposit has no market, side or rate.origin (live, replay, repair or catchup) records how the service ingested the event. Handle every origin the same way.matched.actions lists the actions from your filter that the event satisfied. event.role is more specific: a partly filled resting order has the role maker-partial-fill under the action maker-fill.event.text is for display. Process the structured fields.POST /:id/test sends this instead, through the same queue, retries and daily budget:
{
"id": "test_00000000-0000-4000-8000-000000000001",
"type": "boros.webhook.test",
"version": 1,
"subscription_id": "sub_aaaaaaaaaaaaaaaaaaaa"
}blockNumber, then logIndex. An attempt that timed out may still have been processed, so make your handler idempotent and reply only after the event is stored.gone. Anything else, including a redirect, is a failure.Retry-After is honoured up to the cap.| Limit | Notes |
|---|---|
| 2,000 attempts per subscription per UTC day | Retries and test events count. Events over the limit are dropped, not sent the next day. |
| 20,000 attempts across all subscriptions per UTC day | Shared, so it can run out before your own limit does. |
| 10 active subscriptions; 10 verified per callback host | Checked again at activation. |
| 10 unverified registrations per callback host | The oldest unverified registration may be evicted. |
| 30-day term | Renewing sets expiry to 30 days from the renewal. |
The management page does all of this from a subscription's ID and token. Through the API, paths are relative to the base URL and need the management token. Send a JSON body, {} for the action POSTs; GET and DELETE take none. Timestamps in responses are milliseconds.
| Request | Purpose |
|---|---|
| GET /:id | Status, filter, timestamps, today's usage and the latest 50 delivery receipts. Never returns credentials. |
| PATCH /:id | Change callbackUrl, replace filter, or set paused. Send only the fields you are changing. |
| POST /:id/verify | Send a challenge. Five attempts, including the one sent at registration. |
| POST /:id/renew | Set expiry to 30 days from now. An expired subscription must verify again. |
| POST /:id/rotate-secret | Return a new signing secret, shown once. The token does not change. One rotation per 24 hours. |
| POST /:id/test | Queue a test event. Check the receipts before sending another. |
| DELETE /:id | Delete the subscription. Its attempts still count toward today's shared limit. |
Pause with {"paused":true} and resume with {"paused":false}. An active subscription you paused resumes straight away. One paused for failures, or one whose callback changed, must pass a new challenge. A gone subscription only comes back with a new callback URL and a challenge.
Unverified registrations are removed 24 hours after entering pending_verification; failed challenges do not extend that, and a callback change while pending resets the five attempts but not the 24 hours. An expired subscription can be renewed for 30 days, after which it is removed. Gone subscriptions are also removed after 30 days. A subscription disabled by Boros Explorer stays disabled and cannot be changed, resumed, renewed or deleted.
Receipts are delivered, failed, skipped or orphaned; status 0 means no HTTP response. A skipped attempt hit the daily limit, and an orphaned one was still queued when the webhook stopped being active. Only the latest 50 are kept.
The management token and signing secret cannot be retrieved after the response that returned them. Store both privately and never send them to support.
A 502 with reason outcome-unknown, or a response that never arrived, means the change may have happened. Read the subscription before repeating anything.
secretVersion. If it went up, the new secret cannot be read back and the old one stops working after 24 hours; get in touch before then.Errors return {"ok":false,"reason":"…","message":"…"}. Request bodies are limited to 8,192 bytes, uncompressed, and each request has 15 seconds.
| Status | Meaning |
|---|---|
| 400 / 413 | Invalid request, an unknown field or query parameter, or a body over the limit. |
| 401 / 403 | Wrong ID or token (an unknown ID gets the same answer), or a cross-origin browser request. |
| 405 | Method not allowed; see the Allow header. |
| 408 | The request timed out before it was processed. |
| 409 | Conflicts with the subscription's state or limits, for example no challenge attempts left or a rotation inside its 24 hours. |
| 429 | Rate limited; wait for Retry-After. |
| 502 | No usable answer from the service, or outcome-unknown after a change was sent. |
| 503 | Webhooks are switched off or unavailable. |
Registration allows a burst of three, then one a minute. Management calls allow 20, then one a second.
For history and reconciliation, use the GraphQL API.