# 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_application.created` | Occurs whenever a partner applies to your program (directly, or by completing their application after signup). |
| `partner.application_submitted` | Deprecated. Use `program_application.created` 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](https://github.com/hookdeck/webhook-samples/blob/main/providers/dub/latest/bounty.created.json):

```json
{
  "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

1. In your Dub workspace, open the **Webhooks** settings page and create a webhook.
2. Give it a name and enter your endpoint URL.
3. Select the events you want. For `link.clicked`, choose the folders or links to include if you don't want every click.
4. Save, open the webhook's **Configuration** tab and copy the signing secret. Store all of it, `whsec_` prefix included, for example as `DUB_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](/docs/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](https://dub.co/docs/webhooks/verify-webhook-requests) shows the same steps.

```javascript
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:

```javascript
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](/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 `2xx` for 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.clicked` to 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks) 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](https://hookdeck.com) verifies each Dub request and absorbs your outages, so the webhook stays enabled while your service recovers.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Dub webhooks reliably in minutes.