Stop polling: handling Bondify webhooks correctly the first time

How signed webhook delivery works, the four mistakes that break signature verification, and how to make your handler idempotent against retries.

BT

Bondify Team

June 20, 2026 · 5 min read

On this page

Polling a verify endpoint until a session flips to confirmed is fine for an interactive sign-in screen where a human is staring at it. It's the wrong tool for everything else — onboarding emails, syncing your own database, triggering downstream jobs. For that, you want Bondify to tell you, the moment it happens. That's what webhooks are for.

This post covers the parts that actually trip people up: signature verification, idempotency, and the retry schedule.

Setting one up

A webhook needs three things from your dashboard: a URL, and the secret that's already sitting next to it.

1

Open your project settings

Go to your project on bondify.dev and open Settings.

2

Set the webhook URL

It has to be https://. Save it.

3

Copy the webhook secret

The same whsec_… secret used to verify proofs is used to sign webhook deliveries. One secret, two jobs.

There are exactly two event types: auth.confirmed, fired when the user taps Confirm in Telegram, and auth.cancelled, fired when they tap Cancel. There's deliberately no expired event — a session that nobody acts on just quietly transitions to expired after its 10-minute TTL, with no delivery sent.

The mistake almost everyone makes first

Here's a signature verification function:

ts
1import crypto from 'node:crypto'
2
3function verifyBondifyWebhook(rawBody: string, signature: string, webhookSecret: string): boolean {
4 const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex')
5 const a = Buffer.from(signature, 'hex')
6 const b = Buffer.from(expected, 'hex')
7 return a.length === b.length && crypto.timingSafeEqual(a, b)
8}

Looks straightforward. It will still fail in production for a reason that has nothing to do with the function itself: the body Express hands you by default is already-parsed JSON, not the bytes that arrived on the wire.

ts
1// This breaks signature verification:
2app.post('/webhooks/bondify', express.json(), (req, res) => {
3 verifyBondifyWebhook(JSON.stringify(req.body), ...) // wrong bytes
4})
5
6// This works:
7app.post('/webhooks/bondify', express.raw({ type: 'application/json' }), (req, res) => {
8 verifyBondifyWebhook(req.body.toString('utf8'), ...) // exact bytes
9})

JSON.stringify(JSON.parse(x)) is not guaranteed to equal x — key order, whitespace, and number formatting can all shift. The HMAC was computed over the exact bytes Bondify sent, so any re-serialization, even a semantically identical one, produces a different digest and a signature mismatch that looks like an attack but is actually a body-parsing bug.

Use the raw request body for verification — express.raw(), not express.json(). This is the single most common reason a correctly-written HMAC check "doesn't work."

The signature itself is a hex-encoded SHA-256 HMAC with no sha256= prefix, computed over the raw body only — the timestamp header is not part of what's signed, so don't concatenate it in before hashing.

What you actually receive

auth.confirmed:

json
1{
2 "event": "auth.confirmed",
3 "session_token": "a1b2c3d4e5f6",
4 "telegram_id": "123456789",
5 "telegram_name": "Jane Doe",
6 "telegram_username": "janedoe",
7 "telegram_phone": null,
8 "confirmed_at": 1700000123456
9}

telegram_phone is only populated if you've turned on phone collection (Pro/Business) and the user actually shared it — otherwise it's null. Note there's no proof JWT in this payload. That's intentional: the webhook carries the identity directly, and X-Bondify-Signature is the trust mechanism for it, separate from the JWT-based proof used in the client-polling flow.

Four headers come with every delivery:

X-Bondify-Signature hex HMAC-SHA256 of the raw body X-Bondify-Event-Id stable ID — use this to deduplicate X-Bondify-Timestamp epoch ms when delivery was attempted X-Bondify-Attempt which attempt this is

Retries will duplicate events — design for it

If your endpoint is slow or briefly down, Bondify retries. The schedule backs off from immediate to 30 seconds, 2 minutes, 10 minutes, and on up through several hours, continuing for roughly 72 hours before a delivery is finally marked failed. That's a deliberate design: a transient blip in your infra shouldn't silently lose an auth event.

The flip side is that your handler will receive the same X-Bondify-Event-Id more than once if your first response was slow, dropped, or non-2xx for any reason. Don't process on receipt without checking:

ts
1app.post('/webhooks/bondify', express.raw({ type: 'application/json' }), async (req, res) => {
2 if (!verifyBondifyWebhook(req.body.toString('utf8'), req.headers['x-bondify-signature'], secret)) {
3 return res.status(400).send('Invalid signature')
4 }
5
6 const event = JSON.parse(req.body.toString('utf8'))
7 const eventId = req.headers['x-bondify-event-id']
8
9 if (await alreadyProcessed(eventId)) {
10 return res.json({ ok: true }) // ack, don't reprocess
11 }
12
13 res.json({ ok: true }) // ack immediately, before slow work
14 await markProcessed(eventId)
15 await queue.add('bondify-event', event)
16})

Two things in that snippet matter beyond the dedup check: respond 2xx fast — if your downstream work is slow, acknowledge first and queue the work after, rather than making Bondify wait on it — and verify the signature before doing anything else with the body, including logging it.

If you're on the Node SDK, skip writing this yourself

@bondify/node ships a handler that does signature verification, parsing, and event dispatch for you:

ts
1import express from 'express'
2import { BondifyServer, createWebhookHandler } from '@bondify/node'
3
4const bondify = new BondifyServer({ jwtSecret: process.env.BONDIFY_WEBHOOK_SECRET! })
5
6app.post(
7 '/webhooks/bondify',
8 express.raw({ type: 'application/json' }), // still has to be raw
9 createWebhookHandler(bondify, {
10 onConfirmed: async (event) => {
11 await db.users.upsert({ telegramId: event.telegram_id })
12 },
13 onCancelled: (event) => {
14 // event.session_token, event.cancelled_at
15 },
16 onError: (err, raw) => console.error('Bad webhook:', err.message),
17 })
18)

Next.js apps get an equivalent route handler via createNextWebhookHandler. Either way, you still need the raw body — the SDK can't verify a signature against bytes that have already been re-encoded by a JSON body parser upstream of it.

On Pro and Business plans, every delivery attempt — status, HTTP response code, attempt count, last error — is visible under your project's Webhooks tab, and the same history is queryable via the API. Useful for the inevitable "did this webhook actually fire?" debugging session.

The short version

Verify against the raw body, not the parsed one. Treat every event as potentially duplicated and dedupe on X-Bondify-Event-Id. Acknowledge fast, process asynchronously if the work is slow. Get those three right and webhooks will be the most reliable part of your auth integration, not the most fragile.

Ship messenger auth in 15 minutes

Replace SMS OTP with one-tap sign-in through Telegram, WhatsApp and Discord. Free up to 1,000 MAU.