Gareth Wilson Gareth Wilson

Guide to Dub Webhooks: Features and Best Practices

Published


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

FeatureDetails
AvailabilityBusiness plan and above
ConfigurationWorkspace Webhooks settings page
FilteringChoose events; limit link.clicked to folders or links
Signaturehex HMAC-SHA256
Signature headerdub-signature
Signed contentThe raw request body only
SecretPer webhook, in its Configuration tab, whsec_ prefix included
BodyJSON envelope of id, event, createdAt, data
Event routingBody field event
RetriesUntil delivered: 12 s, 2 min 28 s, 30 min 8 s, 6 h 7 min 6 s, then every 24 hours
Auto-disableOn the 20th consecutive failure, after emails at 5, 10 and 15
Source IPsUpstash 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.

EventMeaning
partner.enrolledOccurs whenever a partner is enrolled in your program (either their application was approved, they accepted your invite, or via the API).
program_application.createdOccurs whenever a partner applies to your program (directly, or by completing their application after signup).
partner.application_submittedDeprecated. Use program_application.created instead. Occurs whenever a partner submits an application to your program.
partner.mergedOccurs when two partner accounts are merged.
lead.createdOccurs whenever a lead is created.
sale.createdOccurs whenever a sale is created.
commission.createdOccurs whenever a commission is created for a partner (clawbacks will also trigger this event with a negative amount).
bounty.createdOccurs 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

  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 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 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.

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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.