Guide to Dub Webhooks: Features and Best Practices
Dub shortens and tracks links, then attributes what happens after the click: leads, sales, and, through Dub Partners, the affiliates who drove them. Webhooks bring that activity into your own systems as it happens. A click can update a dashboard, a sale can trigger a partner payout calculation, and a new programme application can land in your review queue without anyone checking the Dub dashboard.
The sections below cover Dub's link, conversion and partner events, the whsec_ secret that looks like Standard Webhooks but isn't, how to check Dub-Signature, and how Dub's long retry schedule and disable rule should shape your receiver.
What are Dub webhooks?
Dub webhooks belong to a workspace and are available on the Business plan and above. Each has a name, a URL and a set of events. Partner events appear only if the workspace runs a Dub Partners programme, and link.clicked can be limited to particular folders or links so that a busy workspace doesn't send every click.
Dub delivers through Upstash QStash. Every request has the same envelope, { id, event, createdAt, data }, and a Dub-Signature header holding a hex HMAC-SHA256 of the body. The signing secret starts with whsec_, which suggests the Standard Webhooks scheme, but Dub uses the entire string as an ordinary HMAC key and sends no webhook-id or webhook-timestamp headers.
Dub webhook features
| Feature | Details |
|---|---|
| Availability | Business plan and above |
| Configuration | Workspace Webhooks settings page |
| Filtering | Choose events; limit link.clicked to folders or links |
| Signature | hex HMAC-SHA256 |
| Signature header | dub-signature |
| Signed content | The raw request body only |
| Secret | Per webhook, in its Configuration tab, whsec_ prefix included |
| Body | JSON envelope of id, event, createdAt, data |
| Event routing | Body field event |
| Retries | Until delivered: 12 s, 2 min 28 s, 30 min 8 s, 6 h 7 min 6 s, then every 24 hours |
| Auto-disable | On the 20th consecutive failure, after emails at 5, 10 and 15 |
| Source IPs | Upstash QStash addresses, which Dub says can change |
Events and payloads
Route on the top-level event in the {id, event, createdAt, data} envelope. The data object contains the event details.
| Event | Meaning |
|---|---|
partner.enrolled | Occurs whenever a partner is enrolled in your program (either their application was approved, they accepted your invite, or via the API). |
program_ | Occurs whenever a partner applies to your program (directly, or by completing their application after signup). |
partner.application_ | Deprecated. Use program_ instead. Occurs whenever a partner submits an application to your program. |
partner.merged | Occurs when two partner accounts are merged. |
lead.created | Occurs whenever a lead is created. |
sale.created | Occurs whenever a sale is created. |
commission.created | Occurs whenever a commission is created for a partner (clawbacks will also trigger this event with a negative amount). |
bounty.created | Occurs whenever a bounty is created in your program. |
The rest of the list covers links and partner incentives: link.created, link.updated, link.deleted and link.clicked, plus bounty.updated, discount_code.created and discount_code.deleted. Link events don't fire for every change. Bulk link operations, upserts that create a link, edits made in the partner portal and imports all skip them, so a sync built on link.* events alone will drift.
This is the bounty.created sample from the webhook samples library:
{
"id": "evt_KleiO4HBwZFbO1vZLWIPZ2AtX",
"event": "bounty.created",
"createdAt": "2026-10-08T17:34:00.000Z",
"data": {
"id": "bnty_1K39DGZG3MHY9RP4PD0AS2C5P",
"name": "Create a video about Dub and earn $500",
"description": "Create a video about Dub and earn $500.",
"type": "submission",
"startsAt": "2026-10-08T17:34:00.000Z",
"endsAt": "2026-11-08T17:34:00.000Z",
"startMode": "absolute",
"endsAfterDays": null,
"submissionsOpenAt": null,
"submissionFrequency": null,
"maxSubmissions": 1,
"rewardAmount": 50000,
"rewardDescription": null,
"performanceCondition": null,
"socialMetricsLastSyncedAt": null,
"performanceScope": null,
"submissionRequirements": {
"image": {},
"url": {}
},
"groups": [
{
"id": "grp_1K2E25381GVMG7HHM057TB92F"
}
],
"partnerTags": []
}
}
The envelope id starts with evt_ and identifies the event, while data.id identifies the bounty itself. Deduplicate on the envelope id.
Setting up Dub webhooks
- In your Dub workspace, open the Webhooks settings page and create a webhook.
- Give it a name and enter your endpoint URL.
- Select the events you want. For
link.clicked, choose the folders or links to include if you don't want every click. - Save, open the webhook's Configuration tab and copy the signing secret. Store all of it,
whsec_prefix included, for example asDUB_WEBHOOK_SECRET.
Paste the whole secret, including the whsec_ prefix. Dub uses it as a plain HMAC key.
That applies to Hookdeck's Dub source as well as your own code. Create a source with the Dub source type, paste the complete secret as its verification secret, and use the source URL as the webhook URL in Dub. For local testing, forward events with the Hookdeck CLI using npx hookdeck-cli listen 3000 dub --path /webhooks/dub.
Securing Dub webhooks
Compute an HMAC-SHA256 of the raw body with the full secret string as the key, encode it as hex, and compare it with Dub-Signature in constant time after checking it is 64 hex characters. Don't strip whsec_ or base64-decode the rest, as a Standard Webhooks library would. Dub's verification guide shows the same steps.
const crypto = require('node:crypto');
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const signature = headers['dub-signature'];
if (typeof signature !== 'string') return false;
const key = Buffer.from(secret, 'utf8');
const message = rawBody;
const candidates = [signature];
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
const received = Buffer.from(candidate, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
A receiver for conversion and partner events:
const express = require('express');
const app = express();
app.post('/webhooks/dub', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.DUB_WEBHOOK_SECRET; // the whole whsec_... value
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
let parsed;
try {
parsed = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
const { id, event, data } = parsed;
try {
if (await seenEvent(id)) return res.sendStatus(200);
switch (event) {
case 'sale.created':
case 'lead.created':
await recordConversion(event, data);
break;
case 'commission.created':
await updatePartnerBalance(data); // clawbacks arrive as negative amounts
break;
case 'program_application.created':
await queueApplicationReview(data);
break;
default:
break;
}
} catch {
return res.sendStatus(503);
}
res.sendStatus(200);
});
seenEvent, recordConversion, updatePartnerBalance and queueApplicationReview are your own functions.
The signature carries no timestamp, so there is nothing to check for freshness. Hookdeck's Dub source verifies dub-signature the same way, with the full whsec_ string as the key. It answers Dub with 200 and records a failed verification afterwards when the signature is wrong, instead of sending the 401 above.
Make Dub webhooks production-ready. Hookdeck Event Gateway verifies
Dub-Signature, stores every click, lead, sale and partner event, and delivers them to your services with retries and replay you control.
Dub webhook limitations and pain points
A whsec_ secret that isn't Standard Webhooks
The Problem: A Svix or Standard Webhooks library rejects every Dub request.
Why It Happens: Dub's secret starts with whsec_, but Dub signs with the whole string as a plain HMAC key, puts a bare hex digest in Dub-Signature, and sends no webhook-id or webhook-timestamp headers. Standard Webhooks libraries strip the prefix, base64-decode the remainder and expect a different header set.
Workarounds:
- Use a plain HMAC-SHA256 check like the one above.
- Keep the
whsec_prefix in the stored secret.
How Hookdeck Can Help: Hookdeck's Dub source is configured for Dub's actual scheme, and its configuration note reminds you to paste the whole secret.
Twenty failures and the webhook stops
The Problem: A long outage on your side turns the webhook off.
Why It Happens: Dub retries until delivery succeeds, with the gap growing to 24 hours, but it emails you after 5, 10 and 15 consecutive failures and disables the webhook on the 20th. A busy workspace reaches 20 failures quickly.
Workarounds:
- Make sure the failure emails reach someone on call.
- Return
2xxfor every verified event, including types you ignore.
How Hookdeck Can Help: Hookdeck acknowledges Dub immediately, so failures in your service don't count towards Dub's limit, and Hookdeck retries your service on its own schedule.
Not every link change fires an event
The Problem: Your copy of a workspace's links goes out of date.
Why It Happens: Bulk operations, upserts that create links, partner portal edits and imports don't send link.* events.
Workarounds:
- Use link events for real-time updates, and reconcile with Dub's API on a schedule.
How Hookdeck Can Help: Hookdeck's request log shows exactly which link events arrived, which helps you spot the gaps your reconciliation needs to fill.
Click volume
The Problem: link.clicked floods your endpoint during a campaign.
Why It Happens: Every click on a subscribed link produces a delivery.
Workarounds:
- Limit
link.clickedto the folders or links you need. - Send clicks to a separate webhook from leads and sales, so a backlog of clicks can't delay a payout.
How Hookdeck Can Help: Hookdeck queues the burst and delivers it to your service at a rate you choose, so a campaign spike doesn't overload your database.
Best practices
Deduplicate on the event id
Retries carry the same evt_ ID. Store it with the change it caused. Our guide to webhook idempotency covers the patterns.
Handle negative commissions
commission.created also fires for clawbacks, with a negative amount. Add the amount to the partner's balance rather than assuming it is a credit.
Verify the raw body
Read the body as bytes with express.raw({ type: '*/*' }), await request.arrayBuffer() or await request.body(), and parse it only after the HMAC matches.
Don't rely on fixed IPs
Dub's delivery IPs belong to Upstash QStash and can change, so use the signature as the credential. If you also allowlist, refresh the list from the address Dub points to.
Respond, then work
Store the event and return 2xx before recalculating payouts or syncing a CRM. Asynchronous processing keeps you clear of Dub's failure count.
Conclusion
Dub sends link, conversion and partner events in a simple envelope, signed with a hex HMAC-SHA256 keyed with the entire whsec_ secret. Verify with a plain HMAC rather than a Standard Webhooks library, deduplicate on the evt_ ID, filter clicks to what you need, and keep failures below Dub's limit of 20 in a row.
Hookdeck Event Gateway verifies each Dub request and absorbs your outages, so the webhook stays enabled while your service recovers.
Get started with Hookdeck for free and handle Dub webhooks reliably in minutes.