Relevant source files
The following files were used as context for generating this wiki page:
- apps/web/app/api/dub/webhook/route.ts
- apps/web/app/api/webhooks/callback/route.ts
- apps/web/app/ee/api/appsflyer/webhook/route.ts
- apps/web/app/ee/api/partner-profile/postbacks/route.ts
- apps/web/app/ee/api/stripe/integration/webhook/route.ts
- apps/web/app/api/postbacks/callback/route.ts
- apps/web/app/ee/api/paypal/webhook/verify-signature.ts
- apps/web/app/ee/api/stripe/webhook/route.ts
- apps/web/app/ee/api/hubspot/webhook/route.ts
- apps/web/app/ee/api/singular/webhook/route.ts
- apps/web/app/api/veriff/webhook/route.ts
- apps/web/app/ee/api/shopify/integration/webhook/route.ts
- apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx
- apps/web/app/ee/api/hubspot/webhook/process/route.ts
- apps/web/app/ee/api/paypal/webhook/route.ts
- apps/web/app/ee/api/stripe/connect/webhook/route.ts
- apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts
- apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/page.tsx
- apps/web/app/ee/api/intercom/webhook/route.ts
- apps/web/app/ee/api/cron/import/rewardful/route.ts
- apps/web/app/ee/api/partner-profile/postbacks/postbackId/rotate-secret/route.ts
- apps/web/app/ee/api/cloudflare/webhook/r2-object-created/route.ts
- apps/web/app/ee/api/partner-profile/postbacks/postbackId/send-test/route.ts
- apps/web/app/ee/api/intercom/webhook/process/route.ts
- apps/web/lib/postback/postback-adapters.ts
- apps/web/app/ee/api/cron/cleanup/demo-embed-partners/route.ts
- apps/web/lib/webhook/signature.ts
- apps/web/scripts/dev/simulate-shopify-conversion.ts
- apps/web/app/api/resend/webhook/route.ts
- apps/web/ui/postbacks/postback-secret-modal.tsx
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 Type | Payload Source Module |
|---|---|
lead.created | leadCreated (@/lib/postback/sample-events/lead-created.json) |
sale.created | saleCreated (@/lib/postback/sample-events/sale-created.json) |
commission.created | commissionCreated (@/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.
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
Related
On This Page
OverviewOutbound Payload Signing and HMAC ConstructionHMAC Cryptographic GenerationTransport and Header InjectionPostback Adapters and Transformation PipelineAbstract Adapter Class and Event ExecutionCallback URL Construction and QStash DispatchPartner Postback Management and AuthenticationPostback Configuration EndpointsSecret Rotation and Test DeliveryPartner Postback Dashboard InterfacePostback List and Detail ViewsSigning Secret Modal ManagementQStash Delivery Callbacks and Failure TrackingQStash Callback ProcessingFailure Tracking and Tinybird LoggingCore Inbound System Webhook HandlersInternal System Webhook Endpoints and DistributionThird-Party Integration and Cloudflare Webhook HandlersRelated