DocsREST APIWebhooks
REST API

Webhooks

Server-pushed event delivery so you don't have to poll. HMAC-signed payloads, automatic retries with exponential backoff, replay UI for any failed delivery.

v1HMAC-SHA256 signed

How it works

  1. You register an HTTPS endpoint and pick the events you want from /settings/security. We hand you a signing secret once at creation — save it.
  2. Whenever a subscribed event happens on your account (a trade fills, a deposit credits, etc.), we sign a JSON payload with your secret and POST it to your endpoint.
  3. You respond with any 2xx status. Non-2xx, network errors, or timeouts trigger an automatic retry with exponential backoff (5 attempts total over ~2 hours).
  4. If your endpoint racks up 10 consecutive failures, the subscription is auto-disabled. You can re-enable it from /settings/security once you've fixed the root cause.

Create a subscription

Webhook subscriptions are managed from the dashboard — not the public v1 API. Open /settings/security → Webhooks and fill in:

  • Endpoint URL — must be HTTPS in production.
  • Description (optional) — a label so you can tell multiple subscriptions apart.
  • Events — any subset of the catalog below.

Max 5 active subscriptions per account. The signing secret is shown once at creation — store it in your app's secrets manager.

Event catalog

Event
Fires when
webhook.test
You clicked "Send test" in /settings/security. Lets you verify your endpoint and signature handling without waiting for a real platform event. Not subscribable — always delivered on request.
order.filled
A spot order executes against the LP.
deposit.credited
An on-chain deposit clears confirmations and is credited to the wallet.
withdrawal.completed
An outbound withdrawal broadcasts successfully and the tx is on-chain.
withdrawal.rejected
A withdrawal failed and the locked funds have been refunded.
alert.triggered
A price alert hits its target (one-shot — alert is marked triggered).
p2p.trade.created
A counterparty initiates a P2P trade against your offer.
p2p.trade.paid
The P2P buyer marks payment as sent.
p2p.trade.completed
Both sides confirm — escrow releases.
p2p.dispute.opened
Either party raises a dispute on an active P2P trade.

Payload envelope

Every webhook body is the same outer shape — only data changes per event type.

json
1{
2 "id": "evt_d3f4a1b2c5e6f7a8…",
3 "type": "order.filled",
4 "createdAt": "2026-05-22T15:46:23.000Z",
5 "data": {
6 "orderId": "cmpgwygd4000n6bj…",
7 "coin": "BTC",
8 "side": "BUY",
9 "executedPrice": "77352.86",
10 "coinAmount": "0.0000554",
11 "usdtAmount": "4.29"
12 }
13}

id is unique per logical event — use it to deduplicate on your side (we deliver at least once, never less).

Payload data shapes

The data object inside the envelope is event-specific. All numeric values are sent as decimal-formatted strings so JSON parsers don't round 18-decimal balances down to 53-bit floats. Where two parties get notified for the same logical event (P2P), the counterparty's payload includes "counterparty": true.

webhook.testUser-triggered probe — same envelope as a real event.
json
1{
2 "message": "This is a test event from Nextbridge…",
3 "triggeredBy": "user",
4 "docsUrl": "https://nextbridge.exchange/developers/api/webhooks"
5}
order.filled
json
1{
2 "orderId": "cmpgwygd4000n6bj…",
3 "coin": "BTC",
4 "side": "BUY",
5 "executedPrice": "77352.86",
6 "coinAmount": "0.0000554",
7 "usdtAmount": "4.29"
8}
deposit.creditedThe credited asset can differ from the deposited asset — e.g. non-USDT stables credit as USDT.
json
1{
2 "depositId": "cm5z2k8jq000j6bj…",
3 "asset": "USDC",
4 "amount": "100.00",
5 "network": "ERC20",
6 "txHash": "0xa12f…b9c4",
7 "creditedAsset": "USDT",
8 "creditedAmount": "100.00",
9 "usdValue": "100.00"
10}
withdrawal.completed
json
1{
2 "withdrawalId": "cm5x9p1ab000m6bj…",
3 "asset": "USDT",
4 "amount": "50.00",
5 "network": "TRC20",
6 "address": "TXyZ…aB12",
7 "txHash": "9f3a…71c2",
8 "fee": "1.00"
9}
withdrawal.rejectedSent when a broadcast fails and locked funds are refunded. `reason` is human-readable.
json
1{
2 "withdrawalId": "cm5x9p1ab000m6bj…",
3 "asset": "USDT",
4 "amount": "50.00",
5 "network": "TRC20",
6 "address": "TXyZ…aB12",
7 "reason": "Insufficient gas in treasury"
8}
alert.triggeredOne-shot — the alert is marked triggered server-side and will not fire again.
json
1{
2 "alertId": "cm5alrt12000p6bj…",
3 "coin": "BTC",
4 "targetPrice": "78000",
5 "actualPrice": "78014.22",
6 "direction": "ABOVE"
7}
p2p.trade.createdBoth parties receive the same payload; the counterparty's also includes `"counterparty": true`.
json
1{
2 "tradeId": "cm5p2t1xx000q6bj…",
3 "offerId": "cm5p2o9yy000r6bj…",
4 "side": "BUY",
5 "amountUsdt": "200.00",
6 "amountFiat": "300000",
7 "expiresAt": "2026-05-23T12:34:56.000Z"
8}
p2p.trade.paidBuyer marked payment as sent. Counterparty (seller) also gets it with `counterparty: true`.
json
1{
2 "tradeId": "cm5p2t1xx000q6bj…",
3 "amountUsdt": "200.00"
4}
p2p.trade.completedEscrow released. Counterparty payload includes `counterparty: true`.
json
1{
2 "tradeId": "cm5p2t1xx000q6bj…",
3 "amountUsdt": "200.00"
4}
p2p.dispute.opened`reason` is mirrored from the disputing party. Counterparty payload includes `counterparty: true`.
json
1{
2 "tradeId": "cm5p2t1xx000q6bj…",
3 "amountUsdt": "200.00",
4 "reason": "Payment not received after 30 minutes"
5}

Delivery headers

Header
Value
Nextbridge-Signature
t=<unix-ts>,v1=<sha256-hmac-hex>. See "Verifying signatures" below.
Nextbridge-Event-Id
Same as `id` in the body. Use for deduplication.
Nextbridge-Event-Type
Same as `type` in the body. Useful for routing without parsing JSON.
Content-Type
application/json
User-Agent
Nextbridge-Webhooks/1.0

Verifying signatures

The Nextbridge-Signature header has one timestamp and one or more signature parts: t=<unix-ts>,v1=<hex>[,v1=<hex>]. During a secret rotation we emit two v1= values — accept the delivery if any matches your HMAC. Reject anything older than ~5 minutes to prevent replay.

Node.js (Express)

tswebhook-handler.ts
1import crypto from 'node:crypto'
2import express from 'express'
3
4// During rotation, list both secrets (old + new) so either matches.
5const SECRETS = (process.env.NEXTBRIDGE_WEBHOOK_SECRETS ?? '').split(',').filter(Boolean)
6
7export const app = express()
8
9// Capture the raw body — JSON.stringify after the fact would re-key keys
10// in ways that change the signature.
11app.use(express.raw({ type: 'application/json' }))
12
13app.post('/webhooks/nextbridge', (req, res) => {
14 const header = req.header('nextbridge-signature') ?? ''
15 const parts = header.split(',').map(s => s.trim())
16 const ts = parts.find(p => p.startsWith('t='))?.slice(2)
17 const sigs = parts.filter(p => p.startsWith('v1=')).map(p => p.slice(3))
18 if (!ts || sigs.length === 0) return res.status(400).send('bad signature header')
19
20 // Reject anything older than 5 minutes — prevents replay.
21 if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
22 return res.status(400).send('stale')
23 }
24
25 const body = req.body.toString('utf8')
26 const matched = SECRETS.some(secret => {
27 const expected = crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')
28 return sigs.some(s =>
29 s.length === expected.length &&
30 crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s)),
31 )
32 })
33 if (!matched) return res.status(400).send('invalid signature')
34
35 const event = JSON.parse(body)
36 // …do your work, idempotently on event.id…
37 res.status(200).send('ok')
38})

Python (FastAPI)

pythonwebhook_handler.py
1import hmac, hashlib, time, os, json
2from fastapi import FastAPI, Request, HTTPException
3
4# During rotation, list both secrets (old + new) so either matches.
5SECRETS = [s.encode() for s in os.environ["NEXTBRIDGE_WEBHOOK_SECRETS"].split(",") if s]
6app = FastAPI()
7
8@app.post("/webhooks/nextbridge")
9async def webhook(request: Request):
10 header = request.headers.get("nextbridge-signature", "")
11 parts = [p.strip() for p in header.split(",")]
12 ts_part = next((p for p in parts if p.startswith("t=")), None)
13 sigs = [p[3:] for p in parts if p.startswith("v1=")]
14 if not ts_part or not sigs:
15 raise HTTPException(400, "bad signature header")
16 ts = int(ts_part[2:])
17
18 if abs(time.time() - ts) > 300:
19 raise HTTPException(400, "stale")
20
21 body = (await request.body()).decode()
22 matched = any(
23 any(hmac.compare_digest(hmac.new(secret, f"{ts}.{body}".encode(), hashlib.sha256).hexdigest(), s) for s in sigs)
24 for secret in SECRETS
25 )
26 if not matched:
27 raise HTTPException(400, "invalid signature")
28
29 event = json.loads(body)
30 # …do your work, idempotently on event["id"]…
31 return {"ok": True}

Always use a constant-time compare (timingSafeEqual /hmac.compare_digest) — a plain === leaks timing data attackers can use to brute-force a forged signature.

Why the 5-minute window matters

Without the timestamp check, an attacker who captures one of our deliveries off the wire (or out of a misconfigured log) can resend the exact bytes to your endpoint days later and the signature still matches — because botht= and the HMAC are inside the payload they captured. The abs(now - t) < 300 check makes that attack window five minutes wide instead of forever. Pair it with idempotency on event.id for full coverage: even within the 5-minute window, a replayed event is a no-op because you've already processed that id.

Rotating secrets

If your signing secret leaks (committed to a repo, posted in a chat, exposed via a third party), click Rotate on the subscription in /settings/security.

  1. We generate a new secret and show it to you once. The old secret becomes a previous secret with a 24-hour expiry.
  2. During those 24 hours, every delivery is signed with both the new and old secret. The header carries two v1=values; receivers accept the delivery if either matches.
  3. Add the new secret to your config alongside the old one (see the verification snippets above — the example reads NEXTBRIDGE_WEBHOOK_SECRETS as a comma-separated list).
  4. When you've confirmed your endpoint accepts the new secret, remove the old one from your config. After 24h the worker drops it automatically.

Calling rotate again before the previous window closes overwrites the previous secret — only ever two secrets are active at once.

Retries & auto-disable

  • A non-2xx response, network error, or 15-second timeout counts as a failure.
  • Up to 5 total attempts per delivery (1 initial + 4 retries) with exponential backoff managed by Inngest — final attempt is roughly 2 hours after the first.
  • After 10 consecutive failures across deliveries, the subscription auto-disables. New events stop firing to it until you re-enable.
  • Re-enable from /settings/security after your endpoint is healthy again — failure counter resets to 0.

Replay & history

Click Deliveries on any subscription to see its last 50 deliveries with status, HTTP code, attempt count, and timing. Any non-delivered row has a Replay button — clicking it re-enqueues the same payload (same event id, same body, same signature seed) so your endpoint can process it again.

The worker is idempotent on the delivered status — replaying an already-delivered row is a no-op, so you can't accidentally double-deliver from the UI.

Best practices

  • Respond fast. Acknowledge with a 2xx within a few seconds, then do the slow work in a background job. Holding the connection past 15 seconds looks like a failure to us.
  • Process idempotently. Deliveries are at-least-once. Track theid of each event you've already processed and no-op on duplicates.
  • Verify every payload. An unsigned-but-valid-looking POST to your endpoint is the most likely attack vector. Reject anything without a valid signature.
  • Reject stale deliveries. Anything more than ~5 minutes old is probably a replay attempt — reject it even if the signature is valid.
  • Subscribe narrowly. Pick only the event types you actually need so your endpoint isn't spammed with events you discard.