Skip to main content
doc0blade47/dubdocumentation home
Sign in

Webhooks & Postbacks

Last updated Oct 12, 2026100% grounded
Relevant source files

The following files were used as context for generating this wiki page:

Overview

Dub implements an event-driven webhook and postback architecture to synchronize state across external partner programs, payment processors, and notification systems Workspace Subscriptions, Payout Settlement. Inbound endpoints process incoming events from services like Stripe, PayPal, HubSpot, Intercom, Shopify, and Veriff while enforcing cryptographic verification through HMAC signatures and IP allowlist checks Workspace Subscriptions, HubSpot & Intercom, Fraud Prevention, Payout Settlement, Ad Conversion Sync. Outbound notifications and partner postbacks utilize asynchronous delivery pipelines routed through QStash with automatic retry tracking, status logging via Tinybird, and signature verification headers Payout Settlement.

Sources: apps/web/app/api/dub/webhook/route.ts:8-39, apps/web/app/api/webhooks/callback/route.ts:22-101, apps/web/app/ee/api/appsflyer/webhook/route.ts:27-142, apps/web/app/ee/api/stripe/integration/webhook/route.ts:36-190, apps/web/app/ee/api/paypal/webhook/route.ts:21-58, apps/web/lib/postback/postback-adapters.ts:24-68

Outbound Payload Signing and HMAC Construction

HMAC Cryptographic Generation

Outbound postback dispatches authenticate payloads by generating a cryptographic HMAC-SHA256 signature using the Web Crypto API (crypto.subtle). When createWebhookSignature executes, it requires a signing secret; if no secret is present, it immediately throws an error stating that a secret must be provided.

export const createWebhookSignature = async (secret: string, body: any) => {
  if (!secret) {
    throw new Error("A secret must be provided to create a webhook signature.");
  }
 
  const keyData = new TextEncoder().encode(secret);
  const messageData = new TextEncoder().encode(JSON.stringify(body));
 
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    keyData,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );

Sources: apps/web/lib/webhook/signature.ts:2-16

The secret string and the JSON-serialized payload body are encoded into byte arrays using TextEncoder. The raw key data is imported into the Web Crypto runtime specifying the HMAC algorithm and SHA-256 hash function with usage restricted strictly to signing (["sign"]). The resulting signature buffer is converted into an array of unsigned 8-bit integers (Uint8Array) and mapped to a hexadecimal string by padding each byte to two hex characters.

  const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData);
  const signatureArray = Array.from(new Uint8Array(signature));
  const hexSignature = signatureArray
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
 
  return hexSignature;
};

Sources: apps/web/lib/webhook/signature.ts:18-25

Caution

Omitting the postback secret will cause createWebhookSignature to throw an uncaught error and abort signature generation before any network request is dispatched.

Transport and Header Injection

Once the cryptographic signature is derived from the transformed payload, PostbackAdapter injects the signature into the outbound transmission headers. The signature string is assigned to the "Dub-Signature" HTTP header alongside an "Upstash-Hide-Headers" configuration set to "true" to manage QStash transmission properties.

const signature = await createWebhookSignature(
  this.postback.secret,
  transformedPayload,
);
 
const response = await qstash.publishJSON({
  callback: callbackUrl.href,
  failureCallback: callbackUrl.href,
  url: this.postback.url,
  body: transformedPayload,
  headers: {
    "Dub-Signature": signature,
    "Upstash-Hide-Headers": "true",
  },
});

Sources: apps/web/lib/postback/postback-adapters.ts:42-56

Postback Adapters and Transformation Pipeline

Abstract Adapter Class and Event Execution

The PostbackAdapter abstract class serves as the core base engine for processing and dispatching event payloads to configured partner destinations. Subclasses instantiate with a Prisma Postback record and invoke registerEventTransformers() during construction to register specific payload translation rules via an internal PostbackEventTransformers instance.

export interface PostbackPayload {
  eventId: string;
  event: PostbackTrigger;
  createdAt: string;
  data: unknown;
}
 
export abstract class PostbackAdapter {
  protected eventTransformers = new PostbackEventTransformers();
 
  constructor(protected postback: Postback) {
    this.registerEventTransformers();
  }
 
  protected abstract registerEventTransformers(): void;
 

Sources: apps/web/lib/postback/postback-adapters.ts:8-23

When the execute method runs on a PostbackPayload object containing an eventId, event trigger type, createdAt timestamp, and raw data, it first attempts to transform the payload. If the transformer returns a falsy result, execution terminates early and no network request is sent.

async execute(payload: PostbackPayload) {
  const transformedPayload = this.eventTransformers.transform(payload);
 
  if (!transformedPayload) {
    return;
  }
 

Sources: apps/web/lib/postback/postback-adapters.ts:24-30

Note

If eventTransformers.transform(payload) yields a null or undefined payload, execute silently exits without throwing an error or creating delivery artifacts.

Sources: apps/web/lib/postback/postback-adapters.ts:25-29

Callback URL Construction and QStash Dispatch

Postback requests require tracking metadata embedded directly into callback query parameters before being handed off to the asynchronous message queue. The transformation pipeline constructs a search parameters object using the postback identifier, event identifier, and event trigger name, which is then serialized onto the callback destination URL via buildCallbackUrl.

const searchParams = {
  postbackId: this.postback.id,
  eventId: payload.eventId,
  event: payload.event,
};
 
const callbackUrl = buildCallbackUrl(
  `${APP_DOMAIN_WITH_NGROK}/api/postbacks/callback`,
  searchParams,
);
 

Sources: apps/web/lib/postback/postback-adapters.ts:31-41

After computing the HMAC webhook signature using the target postback secret and transformed payload, PostbackAdapter publishes the JSON payload to QStash. The publication configuration specifies both the success and failure callback URLs, the destination URL configured on the postback record, and request headers including the signature.

const signature = await createWebhookSignature(
  this.postback.secret,
  transformedPayload,
);
 
const response = await qstash.publishJSON({
  callback: callbackUrl.href,
  failureCallback: callbackUrl.href,
  url: this.postback.url,
  body: transformedPayload,
  headers: {
    "Dub-Signature": signature,
    "Upstash-Hide-Headers": "true",
  },
});

Sources: apps/web/lib/postback/postback-adapters.ts:42-56

Sources: apps/web/lib/postback/postback-adapters.ts:24-60

Postback delivery attempts enforce runtime logging behavior based on the message response state and environment variables. If the response object lacks a messageId, an error is logged to the console, and development environments write a detailed debug log containing the full response and transformed payload.

  if (!response.messageId) {
    console.error("Failed to publish postback event to QStash", response);
  }
 
  if (process.env.NODE_ENV === "development") {
    console.debug("Published postback event to QStash", {
      ...response,
      payload: transformedPayload,
    });
  }
}

Sources: apps/web/lib/postback/postback-adapters.ts:58-68

Partner Postback Management and Authentication

Postback Configuration Endpoints

Partner postback configurations are managed via API routes protected by partner profile authentication, requiring the postbacks feature flag. The GET /api/partner-profile/postbacks route fetches all postback configurations associated with the authenticated partner identifier, ordered descending by their creation timestamp, and validates the output against postbackSchema.

// GET /api/partner-profile/postbacks
export const GET = withPartnerProfile(
  async ({ partner }) => {
    const postbacks = await prisma.postback.findMany({
      where: {
        partnerId: partner.id,
      },
      orderBy: {
        createdAt: "desc",
      },
    });
 
    return NextResponse.json(z.array(postbackSchema).parse(postbacks));
  },
  {
    requiredPermission: "postbacks.read",
    featureFlag: "postbacks",
  },
);

Sources: apps/web/app/ee/api/partner-profile/postbacks/route.ts:21-39

The POST /api/partner-profile/postbacks endpoint parses input parameters via createPostbackInputSchema, enforces a maximum resource limit defined by MAX_POSTBACKS, and generates a secure authentication token for the postback secret using POSTBACK_SECRET_PREFIX and POSTBACK_SECRET_LENGTH. New postback records are initialized with an identifier prefixed by pb_, receiver channels identified via identifyPostbackChannel, and associated triggers.

// POST /api/partner-profile/postbacks
export const POST = withPartnerProfile(
  async ({ partner, req }) => {
    const { name, url, triggers } = createPostbackInputSchema.parse(
      await parseRequestBody(req),
    );
 
    const postbackCount = await prisma.postback.count({
      where: {
        partnerId: partner.id,
      },
    });
 
    if (postbackCount >= MAX_POSTBACKS) {
      throw new DubApiError({
        code: "exceeded_limit",
        message: `Maximum number of postbacks (${MAX_POSTBACKS}) reached.`,
      });
    }
 
    const secret = createToken({
      prefix: POSTBACK_SECRET_PREFIX,
      length: POSTBACK_SECRET_LENGTH,
    });
 
    const postback = await prisma.postback.create({
      data: {
        id: createId({ prefix: "pb_" }),
        partnerId: partner.id,
        name,
        url,
        secret,
        triggers,
        receiver: identifyPostbackChannel(url),
      },
    });
 
    return NextResponse.json(createPostbackOutputSchema.parse(postback), {
      status: 201,
    });
  },
  {
    requiredPermission: "postbacks.write",
    featureFlag: "postbacks",
  },
);

Sources: apps/web/app/ee/api/partner-profile/postbacks/route.ts:41-86

Warning

Attempting to create a postback when the existing count equals or exceeds MAX_POSTBACKS throws a DubApiError with the exceeded_limit error code, aborting the write operation before record creation.

Sources: apps/web/app/ee/api/partner-profile/postbacks/route.ts:48-59

Secret Rotation and Test Delivery

Secret rotation and test dispatch handlers provide ongoing management and diagnostic validation for existing postback configurations. The POST /api/partner-profile/postbacks/[postbackId]/rotate-secret endpoint validates the target postback using getPostbackOrThrow, generates a new cryptographic token with standard prefix and length constraints, updates the database record, and returns the updated secret value.

// POST /api/partner-profile/postbacks/[postbackId]/rotate-secret
export const POST = withPartnerProfile(
  async ({ partner, params }) => {
    const { postbackId } = params;
 
    await getPostbackOrThrow({
      postbackId,
      partnerId: partner.id,
    });
 
    const secret = createToken({
      prefix: POSTBACK_SECRET_PREFIX,
      length: POSTBACK_SECRET_LENGTH,
    });
 
    await prisma.postback.update({
      where: {
        id: postbackId,
      },
      data: {
        secret,
      },
    });
 
    return NextResponse.json({ secret });
  },
  {
    requiredPermission: "postbacks.write",
    featureFlag: "postbacks",
  },
);

Sources: apps/web/app/ee/api/partner-profile/postbacks/postbackId/rotate-secret/route.ts:11-41

Diagnostic test events are dispatched via POST /api/partner-profile/postbacks/[postbackId]/send-test, which maps supported trigger types to predefined sample payloads.

Trigger TypePayload Source Module
lead.createdleadCreated (@/lib/postback/sample-events/lead-created.json)
sale.createdsaleCreated (@/lib/postback/sample-events/sale-created.json)
commission.createdcommissionCreated (@/lib/postback/sample-events/commission-created.json)

Sources: apps/web/app/ee/api/partner-profile/postbacks/postbackId/send-test/route.ts:5-17

Before triggering test deliveries, the endpoint verifies that the requested event type is included in the postback's configured triggers array, throwing a bad_request error if unconfigured. Test payloads are dispatched through sendPartnerPostback with skipEnrichment enabled.

// POST /api/partner-profile/postbacks/[postbackId]/send-test
export const POST = withPartnerProfile(
  async ({ partner, params, req }) => {
    const { postbackId } = params;
 
    const { event } = sendTestPostbackInputSchema.parse(
      await parseRequestBody(req),
    );
 
    const postback = await getPostbackOrThrow({
      postbackId,
      partnerId: partner.id,
    });
 
    const triggers = postback.triggers as string[];
 
    if (!triggers.includes(event)) {
      throw new DubApiError({
        code: "bad_request",
        message: "The selected event is not configured for this postback.",
      });
    }
 
    await sendPartnerPostback({
      partnerId: partner.id,
      event,
      data: samplePayloads[event],
      skipEnrichment: true,
    });
 
    return NextResponse.json({});
  },
  {
    requiredPermission: "postbacks.write",
    featureFlag: "postbacks",
  },
);

Sources: apps/web/app/ee/api/partner-profile/postbacks/postbackId/send-test/route.ts:19-55

Partner Postback Dashboard Interface

Postback List and Detail Views

The partner portal provides dedicated React client components for inspecting and managing postbacks under the /profile/postbacks and /profile/postbacks/[postbackId] routes. The list view utilizes SWR to fetch all postback records from /api/partner-profile/postbacks, rendering either loading skeletons (PostbackPlaceholder), a list of PostbackCard components, or an empty state warning when no configurations exist.

const {
  data: postbacks,
  error,
  isLoading,
  mutate,
} = useSWR<PostbackProps[]>("/api/partner-profile/postbacks", fetcher, {
  keepPreviousData: true,
});
 
const { openPostbackSecretModal, PostbackSecretModal } =
  usePostbackSecretModal();
 
const { openAddPostbackModal, AddEditPostbackModal } =
  useAddEditPostbackModal(() => mutate(), openPostbackSecretModal);
 
return (
  <>
    <PageContent
      title="Postbacks"
      titleInfo={{
        title:
          "Receive HTTP requests when events like leads, sales, or commissions occur in your partner programs.",
        href: "https://d.to/postbacks",
      }}
      controls={<AddPostbackButton onClick={openAddPostbackModal} />}
    >
      <PageWidthWrapper>
        <div className="grid gap-5">
          <div className="animate-fade-in">
            {isLoading ? (
              <div className="grid grid-cols-1 gap-3">
                {Array.from({ length: 3 }).map((_, idx) => (
                  <PostbackPlaceholder key={idx} />
                ))}
              </div>
            ) : error ? (
              <div className="flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10">
                <EmptyStateBlock
                  icon={AlertCircle}
                  title="Failed to load postbacks"
                  description={
                    error instanceof Error
                      ? error.message
                      : "Something went wrong. Please try again."
                  }
                >
                  <button
                    type="button"
                    onClick={() => mutate()}
                    className="flex h-8 items-center justify-center gap-2 rounded-md border border-neutral-200 bg-white px-4 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50"
                  >
                    Try again
                  </button>
                </EmptyStateBlock>
              </div>
            ) : postbacks && postbacks.length > 0 ? (
              <div className="grid grid-cols-1 gap-3">
                {postbacks.map((postback) => (
                  <PostbackCard key={postback.id} {...postback} />
                ))}
              </div>
            ) : (
              <div className="flex flex-col items-center gap-4 rounded-xl border border-neutral-200 py-10">
                <EmptyState
                  icon={Webhook}
                  title="You haven't set up any postbacks yet."
                  description="Postbacks allow you to receive HTTP requests when events like leads, sales, or commissions occur in your partner programs."
                  learnMore="https://d.to/postbacks"
                />
              </div>
            )}

Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/page.tsx:18-88

When navigating to a specific configuration via postbackId, the detail page fetches endpoint metadata alongside logged delivery events from /api/partner-profile/postbacks/[postbackId]/events with previous data preservation enabled. If the endpoint returns a 404 error code, the client redirects the user back to the main postbacks index.

export default function PostbackDetailPage() {
  const router = useRouter();
  const { postbackId } = useParams();
  const [openPopover, setOpenPopover] = useState(false);
 
  const {
    data: postback,
    error,
    isLoading,
    mutate,
  } = useSWR<PostbackProps>(
    postbackId ? `/api/partner-profile/postbacks/${postbackId}` : null,
    fetcher,
  );
 
  const {
    data: events,
    error: eventsError,
    isLoading: isEventsLoading,
  } = useSWR<PostbackEventProps[]>(
    postbackId ? `/api/partner-profile/postbacks/${postbackId}/events` : null,
    fetcher,
    { keepPreviousData: true },
  );
 
  const { postbackEventDetailsSheet, openWithEvent } =
    usePostbackEventDetailsSheet();
 
  const { openPostbackSecretModal, PostbackSecretModal } =
    usePostbackSecretModal();
 
  const { openEditPostbackModal, AddEditPostbackModal } =
    useAddEditPostbackModal(() => mutate(), openPostbackSecretModal);
 
  if (Boolean(error && (error as { status?: number }).status === 404)) {
    redirect("/profile/postbacks");
  }

Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:22-58

Note

The detail view renders a token avatar derived from the postback identifier, destination URL, status badge (PostbackStatus), and action popovers alongside an interactive event list supporting click-to-inspect drawers.

Sources: apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:92-150

Signing Secret Modal Management

The usePostbackSecretModal hook manages the state for displaying newly generated or rotated cryptographic secrets to partners. It exposes a helper function to open the modal with a specified secret string and returns a stable PostbackSecretModal element wrapper that prevents unnecessary remounts during parent re-renders.

export function usePostbackSecretModal() {
  const [state, setState] = useState<{ show: boolean; secret: string }>({
    show: false,
    secret: "",
  });
 
  function openPostbackSecretModal(secret: string) {
    setState({ show: true, secret });
  }
 
  function closePostbackSecretModal() {
    setState({ show: false, secret: "" });
  }
 
  return {
    openPostbackSecretModal,
    closePostbackSecretModal,
    // Return an element (rendered as `{PostbackSecretModal}`) rather than a
    // per-render component type, which would remount the modal – and reset its
    // state – on every parent render
    PostbackSecretModal: (
      <PostbackSecretModal
        showModal={state.show}
        setShowModal={(show) => {
          const next = typeof show === "function" ? show(state.show) : show;
          if (!next) closePostbackSecretModal();
        }}
        secret={state.secret}
      />
    ),
    isPostbackSecretModalOpen: state.show,
  };
}

Sources: apps/web/ui/postbacks/postback-secret-modal.tsx:74-106

Inside the modal component, partners can view their monospace-formatted signing secret and utilize a clipboard copy button backed by useCopyToClipboard and sonner toast notifications.

function PostbackSecretModal({
  showModal,
  setShowModal,
  secret,
}: PostbackSecretModalProps) {
  const [copied, copyToClipboard] = useCopyToClipboard();
 
  return (
    <Modal
      showModal={showModal}
      setShowModal={setShowModal}
      className="max-w-md"
    >
      <div className="space-y-2 border-b border-neutral-200 px-4 py-4 sm:px-6">
        <h3 className="text-lg font-medium">Signing secret</h3>
        <p className="text-sm text-neutral-500">
          Copy it and store it somewhere safe. You will need it to verify
          postback signatures.
        </p>
      </div>
 
      <div className="flex flex-col space-y-4 bg-neutral-50 px-4 py-8 sm:px-6">
        <div className="flex flex-col gap-1">
          <h2 className="text-sm font-medium text-neutral-800">
            Signing secret
          </h2>
          <div className="flex items-center justify-between gap-2 rounded-md border border-neutral-200 bg-white p-2">
            <p className="truncate font-mono text-sm text-neutral-500">
              {secret}
            </p>
            <button
              onClick={(e) => {
                e.stopPropagation();
                toast.promise(copyToClipboard(secret), {
                  success: "Secret copied to clipboard!",
                });
              }}
              type="button"
              className="flex h-7 shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-2 py-1 text-xs font-medium text-neutral-900 hover:bg-neutral-50"
            >
              {copied ? (
                <Tick className="h-3.5 w-3.5" />
              ) : (
                <Copy className="h-3.5 w-3.5" />
              )}
              {copied ? "Copied" : "Copy"}
            </button>
          </div>
        </div>
 
        <Button
          text="Done"
          variant="secondary"
          onClick={() => setShowModal(false)}
          className="w-full"
        />
      </div>
    </Modal>
  );
}

Sources: apps/web/ui/postbacks/postback-secret-modal.tsx:13-72

QStash Delivery Callbacks and Failure Tracking

QStash Callback Processing

Asynchronous webhook and postback delivery receipts dispatched by QStash are received at /api/webhooks/callback and /api/postbacks/callback. Incoming requests are authenticated using verifyQstashSignature against the raw request body. Query parameters are extracted and validated using Zod schemas to identify the associated resource, event type, and event identifier.

const searchParamsSchema = z.object({
  webhookId: z.string(),
  eventId: z.string(),
  event: z.enum(WEBHOOK_TRIGGERS),
  failed: z.literal("true").optional(),
});
 
// POST /api/webhooks/callback – listen to webhooks status from QStash
export const POST = async (req: Request) => {
  const rawBody = await req.text();
  await verifyQstashSignature({ req, rawBody });
 
  const { url, status, body, sourceBody, sourceMessageId } =
    webhookCallbackSchema.parse(JSON.parse(rawBody));
 
  const {
    webhookId,
    eventId,
    event,
    failed: deliveryFailed, // failed after all the retries
  } = searchParamsSchema.parse(getSearchParams(req.url));
 

Sources: apps/web/app/api/webhooks/callback/route.ts:14-35

export const POST = async (req: Request) => {
  const rawBody = await req.text();
 
  await verifyQstashSignature({
    req,
    rawBody,
  });
 
  const { status, url, sourceMessageId, body, sourceBody, retried } =
    postbackCallbackBodySchema.parse(JSON.parse(rawBody));
 
  const { postbackId, eventId, event } = postbackCallbackParamsSchema.parse(
    getSearchParams(req.url),
  );

Sources: apps/web/app/api/postbacks/callback/route.ts:12-25

For webhooks, the system queries the database for the corresponding webhook configuration. If the record does not exist, an error response is returned. Base64-encoded source payloads and response bodies are decoded into UTF-8 strings. A status code of -1 is normalized to 503 to represent connection timeouts or network failures.

const webhook = await prisma.webhook.findUnique({
  where: {
    id: webhookId,
  },
});
 
if (!webhook) {
  console.error("Webhook not found", { webhookId });
  return new Response("Webhook not found");
}
 
const request = Buffer.from(sourceBody, "base64").toString("utf-8");
const response = Buffer.from(body, "base64").toString("utf-8");
const isFailed = status >= 400 || status === -1;
 

Sources: apps/web/app/api/webhooks/callback/route.ts:36-50

Note

If a Zapier webhook returns an HTTP 410 Gone status, the webhook record is deleted automatically to clean up uninstalled integrations.

Sources: apps/web/app/api/webhooks/callback/route.ts:51-64

Failure Tracking and Tinybird Logging

Delivery outcomes are recorded asynchronously using Promise.allSettled. Webhook events are logged to Tinybird via recordWebhookEvent, storing the URL, event trigger, event ID, HTTP status, request body, response body, and message ID. Postback events are recorded via recordPostbackEvent, which checks for successful row insertions and returns a 400 error response if Tinybird ingestion fails.

const tbResponse = await recordPostbackEvent({
  event_id: eventId,
  postback_id: postbackId,
  message_id: sourceMessageId,
  event,
  url,
  response_status: status === -1 ? 503 : status,
  request_body: request,
  response_body: response,
  retry_attempt: retried,
});
 
if (tbResponse.successful_rows === 0) {
  return logAndRespond(
    `Failed to record event ${eventId} for postback ${postbackId}.`,
    {
      status: 400,
    },
  );
}
…
const tbResponse = await recordPostbackEvent({
  event_id: eventId,
  postback_id: postbackId,
  message_id: sourceMessageId,
  event,
  url,
  response_status: status === -1 ? 503 : status,
  request_body: request,
  response_body: response,
  retry_attempt: retried,
});
 
if (tbResponse.successful_rows === 0) {
  return logAndRespond(
    `Failed to record event ${eventId} for postback ${postbackId}.`,
    {
      status: 400,
    },
  );
}

Sources: apps/web/app/api/postbacks/callback/route.ts:43-62, apps/web/app/api/postbacks/callback/route.ts:43-62

The system evaluates delivery failures based on HTTP status codes where status >= 400 || status === -1. When a failure occurs on the final retry attempt, handleWebhookFailure is invoked. Conversely, if a delivery succeeds while consecutive failures exceed zero, resetWebhookFailureCount clears the failure counter. External payout events for payout.confirmed triggers are processed with a mapped status of failure, temporary_failure, or success.

await Promise.allSettled([
  // Record the webhook event
  recordWebhookEvent({
    url,
    event,
    event_id: eventId,
    http_status: status === -1 ? 503 : status,
    webhook_id: webhookId,
    request_body: request,
    response_body: response,
    message_id: sourceMessageId,
  }),
 
  // Handle the webhook delivery failure if it's the last retry
  ...(isFailed ? [handleWebhookFailure(webhookId)] : []),
 
  // Only reset if there were previous failures
  ...(webhook.consecutiveFailures > 0 && !isFailed
    ? [resetWebhookFailureCount(webhookId)]
    : []),
 
  // Handle payout events
  ...(event === "payout.confirmed"
    ? [
        handleExternalPayoutEvent({
          webhook,
          payload: JSON.parse(request),
          status: deliveryFailed
            ? "failure"
            : isFailed
              ? "temporary_failure"
              : "success",
        }),
      ]
    : []),
]);

Sources: apps/web/app/api/webhooks/callback/route.ts:66-101

Core Inbound System Webhook Handlers

Internal System Webhook Endpoints and Distribution

The system manages inbound notifications through dedicated route handlers, including /api/dub/webhook, storage event forwarders, and integration receivers. Incoming requests extract cryptographic signatures and validate them using timingSafeCompare to prevent timing attacks against webhook secrets. For instance, the main Dub webhook route checks the Dub-Signature header against an HMAC-SHA256 hash computed over the request body using process.env.DUB_WEBHOOK_SECRET.

import { withAxiom } from "@/lib/axiom/server";
import { webhookPayloadSchema } from "@/lib/webhook/schemas";
import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare";
import crypto from "crypto";
import { leadCreated } from "./lead-created";
import { saleCreated } from "./sale-created";
 
// POST /api/dub/webhook - receive webhooks for Dub
export const POST = withAxiom(async (req: Request) => {
  const body = await req.json();
  const { event, data } = webhookPayloadSchema.parse(body);
 
  const webhookSignature = req.headers.get("Dub-Signature");
 
  if (!webhookSignature) {
    return new Response("No signature provided", { status: 401 });
  }
 
  const computedSignature = crypto
    .createHmac("sha256", `${process.env.DUB_WEBHOOK_SECRET}`)
    .update(JSON.stringify(body))
    .digest("hex");
 
  if (!timingSafeCompare(webhookSignature, computedSignature)) {
    return new Response("Invalid signature", { status: 400 });
  }

Sources: apps/web/app/api/dub/webhook/route.ts:1-26

Once validated, incoming payloads are parsed against Zod schemas and distributed according to their event types. The main webhook handler processes referral and conversion triggers using a switch statement over the event payload property.

  let response = "OK";
 
  switch (event) {
    case "lead.created": // new signup via referral link (lead event)
      response = await leadCreated(data);
      break;
    case "sale.created": // new sale via referral link (sale event)
      response = await saleCreated(data);
      break;
  }
 
  return new Response(response);
});

Sources: apps/web/app/api/dub/webhook/route.ts:28-40

Warning

Requests lacking a valid signature header return an immediate 401 unauthorized response before any JSON body parsing occurs.

Sources: apps/web/app/api/dub/webhook/route.ts:13-17

Third-Party Integration and Cloudflare Webhook Handlers

Third-party integrations process inbound notifications from external platforms such as Stripe, HubSpot, Intercom, PayPal, and Cloudflare R2 storage workers. Each integration utilizes distinct signature validation mechanisms: Cloudflare worker requests verify bearer tokens using verifyWorkerSecret, while PayPal webhooks validate transmission headers and certificates against an allowlist.

const r2ObjectCreatedWebhookBodySchema = z.object({
  notification: r2ObjectCreatedNotificationSchema,
});
 
function verifyWorkerSecret(req: Request) {
  const webhookSecret = assertEnv("CLOUDFLARE_WORKER_WEBHOOK_SECRET");
 
  const auth = req.headers.get("authorization") || "";
  const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length) : "";
 
  return timingSafeCompare(token, webhookSecret);
}
 
// POST /api/cloudflare/webhook/r2-object-created
// Called by the Cloudflare queue consumer Worker (thin forwarder).
export async function POST(req: Request) {
  try {
    if (!verifyWorkerSecret(req)) {
      return NextResponse.json(
        {
          error: "Unauthorized",
        },
        {
          status: 401,
        },
      );
    }

Sources: apps/web/app/ee/api/cloudflare/webhook/r2-object-created/route.ts:15-41

export async function verifySignature({
  event,
  headers,
}: {
  event: string; // raw event data as string
  headers: Headers;
}) {
  const transmissionId = headers.get("paypal-transmission-id");
  const transmissionSig = headers.get("paypal-transmission-sig");
  const timeStamp = headers.get("paypal-transmission-time");
  const certUrl = headers.get("paypal-cert-url");
 
  if (!transmissionId || !transmissionSig || !timeStamp || !certUrl) {
    console.error(
      "[PayPal] Missing required headers for signature verification",
    );
    return false;
  }
 
  if (!isValidPayPalCertUrl(certUrl)) {
    console.error(`[PayPal] Rejected non-PayPal certificate URL: ${certUrl}`);
    return false;
  }
 
  const certPem = await downloadAndCache(certUrl);
 
  if (!certPem) {
    console.error("[PayPal] Failed to download or cache PayPal certificate");
    return false;
  }
 
  const crc = parseInt("0x" + crc32(event).toString("hex"));
  const message = `${transmissionId}|${timeStamp}|${paypalEnv.PAYPAL_WEBHOOK_ID}|${crc}`;
  const signatureBuffer = Buffer.from(transmissionSig, "base64");
 
  const verifier = crypto.createVerify("SHA256");
  verifier.update(message);
 
  return verifier.verify(certPem, new Uint8Array(signatureBuffer));
}

Sources: apps/web/app/ee/api/paypal/webhook/verify-signature.ts:62-101

Stripe integration and connect webhooks construct events using stripe.webhooks.constructEvent with Stripe-Signature verification against environment secrets. Relevant Stripe events are filtered before dispatching to specific workflow functions.

Event TypeTarget Handler / ActionSources
account.application.deauthorizedaccountApplicationDeauthorizedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:23-138
charge.refundedchargeRefundedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:24-145
checkout.session.completedcheckoutSessionCompletedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:25-152
coupon.deletedcouponDeletedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:26-157
customer.created / customer.updatedsyncCustomerapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:27-164
customer.subscription.createdcustomerSubscriptionCreatedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:29-171
customer.subscription.deletedcustomerSubscriptionDeletedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:30-175
invoice.paidinvoicePaidapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:31-181
promotion_code.updatedpromotionCodeUpdatedapps/web/app/(ee)/api/stripe/integration/webhook/route.ts:32-187

Sources: apps/web/app/ee/api/stripe/integration/webhook/route.ts:22-190

Tip

When Stripe rate limit or lock timeout errors occur during webhook execution, handlers catch the error and return an HTTP 429 status code, prompting Stripe to retry the event instead of treating it as an internal server error.

Sources: apps/web/app/ee/api/stripe/integration/webhook/route.ts:191-204

Was this page helpful?