# blade47/dub Wiki
## Technical docs: dub API
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/api-overview
# dub API
**Version:** inferred
**148** endpoints detected from `(inferred from code)`
## Endpoints
### Admin
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/analytics` | Get analytics for admin |
| `POST` | `/api/admin/ban` | Ban a user and their associated workspaces |
| `GET` | `/api/admin/commissions` | Get admin commissions data |
| `POST` | `/api/admin/domains/refresh` | Refresh a domain on Vercel |
| `POST` | `/api/admin/domains/register-premium` | Register a premium .link domain |
| `POST` | `/api/admin/domains/renew` | Renew a registered domain |
| `GET` | `/api/admin/domains/search-availability` | Search a .link domain availability |
| `GET` | `/api/admin/events` | Get events for admin |
| `PATCH` | `/api/admin/fraud-alerts/{fraudAlertId}` | Review and update fraud alert status |
| `POST` | `/api/admin/impersonate` | Impersonate a user or workspace |
| `DELETE` | `/api/admin/links/ban` | Ban a link by domain and key |
| `GET` | `/api/admin/links/count` | Count or group links |
| `GET` | `/api/admin/links` | Get admin links list |
| `POST` | `/api/admin/partners/{partnerId}/generate-veriff-session` | Generate Veriff session for a partner |
| `PATCH` | `/api/admin/partners/{partnerId}/network-status` | Update partner network status |
| `POST` | `/api/admin/partners/{partnerId}/platforms` | Add or update a partner platform |
| `GET` | `/api/admin/payouts/stablecoin` | Get stablecoin payouts |
| `PATCH` | `/api/admin/programs/{programId}` | Update program marketplace details |
| `DELETE` | `/api/admin/programs/{programId}` | Remove program from marketplace |
| `POST` | `/api/admin/programs/delete` | Delete program completely |
| `GET` | `/api/admin/programs/recent` | Get recent programs |
| `GET` | `/api/admin/programs` | List marketplace programs |
| `POST` | `/api/admin/programs` | Add program to marketplace |
| `PATCH` | `/api/admin/programs` | Reorder marketplace programs |
| `GET` | `/api/admin/programs/sales` | Get top programs by sales |
| `POST` | `/api/admin/reset-login-attempts` | Reset login attempts |
| `GET` | `/api/admin/revenue` | Get admin revenue timeseries |
| `POST` | `/api/admin/slack-support-invite` | Send Slack support channel invite |
| `POST` | `/api/admin/workspaces/disable` | Disable a workspace |
| `POST` | `/api/admin/workspaces/restore` | Restore a workspace |
### Admin Partners
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/partners/{partnerId}` | Get admin partner details |
| `PATCH` | `/api/admin/partners/{partnerId}` | Update partner country |
| `GET` | `/api/admin/partners/{partnerId}/shared-platforms` | Get shared platforms |
| `POST` | `/api/admin/partners/{partnerId}/verify-identity` | Verify partner identity |
| `POST` | `/api/admin/partners/delete-account` | Delete partner account |
| `GET` | `/api/admin/partners/fraud` | Get fraud alerts |
| `GET` | `/api/admin/partners/network/count` | Get network partners count |
| `GET` | `/api/admin/partners/network` | Get network partners list |
| `GET` | `/api/admin/partners/trusted` | Get trusted partners |
| `POST` | `/api/admin/partners/trusted` | Mark partner as trusted |
| `DELETE` | `/api/admin/partners/trusted` | Remove trusted partner status |
### Admin Payouts
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/payouts/paypal` | Get pending PayPal payouts |
| `GET` | `/api/admin/payouts` | Get admin payouts and timeseries |
### Webhooks
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/appsflyer/webhook` | AppsFlyer Postback Webhook |
| `HEAD` | `/api/appsflyer/webhook` | AppsFlyer Webhook Health Check |
| `POST` | `/api/cloudflare/webhook/r2-object-created` | Cloudflare R2 object created webhook |
### Audit Logs
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/audit-logs/export` | Export audit logs |
### Auth
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/auth/saml/authorize` | SAML Authorize GET |
| `POST` | `/api/auth/saml/authorize` | SAML Authorize POST |
| `POST` | `/api/auth/saml/callback` | SAML Callback |
| `POST` | `/api/auth/saml/token` | SAML Token Endpoint |
| `GET` | `/api/auth/saml/userinfo` | SAML User Info |
| `POST` | `/api/auth/saml/verify` | Verify SAML Connection |
### Bounties
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/bounties/{bountyId}` | Get a bounty |
| `PATCH` | `/api/bounties/{bountyId}` | Update a bounty |
| `DELETE` | `/api/bounties/{bountyId}` | Delete a bounty |
| `POST` | `/api/bounties/{bountyId}/submissions/{submissionId}/approve` | Approve a submission |
| `POST` | `/api/bounties/{bountyId}/submissions/{submissionId}/reject` | Reject a submission |
| `GET` | `/api/bounties/{bountyId}/submissions` | Get all submissions for a bounty |
| `POST` | `/api/bounties/{bountyId}/sync-social-metrics` | Sync social metrics for a bounty |
| `GET` | `/api/bounties/count/submissions` | Get total bounty submissions count |
| `GET` | `/api/bounties` | Get all bounties |
| `POST` | `/api/bounties` | Create a bounty |
### Campaigns
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/campaigns/{campaignId}/duplicate` | Duplicate campaign |
| `GET` | `/api/campaigns/{campaignId}/events/count` | Get campaign events count |
| `GET` | `/api/campaigns/{campaignId}/events` | Get campaign events |
| `POST` | `/api/campaigns/{campaignId}/preview` | Send campaign preview email |
| `GET` | `/api/campaigns/{campaignId}` | Get email campaign |
| `PATCH` | `/api/campaigns/{campaignId}` | Update email campaign |
| `DELETE` | `/api/campaigns/{campaignId}` | Delete email campaign |
| `GET` | `/api/campaigns/{campaignId}/summary` | Get campaign summary |
| `GET` | `/api/campaigns/count` | Get campaign count |
| `GET` | `/api/campaigns` | Get all campaigns |
| `POST` | `/api/campaigns` | Create campaign |
### Commissions
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/commissions/{commissionId}` | Get commission by ID |
| `PATCH` | `/api/commissions/{commissionId}` | Update commission |
| `GET` | `/api/commissions/analytics` | Get commission analytics |
| `PATCH` | `/api/commissions/bulk` | Bulk update commission status |
| `GET` | `/api/commissions/count` | Get commissions count |
| `GET` | `/api/commissions/export` | Export commissions to CSV |
| `GET` | `/api/commissions` | Get all commissions |
| `POST` | `/api/commissions` | Create manual commission |
### Cron
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/cron/aggregate-clicks` | Aggregate clicks cron |
| `POST` | `/api/cron/bounties/notify-partners` | Notify partners about new bounties |
| `GET` | `/api/cron/bounties/queue-sync-social-metrics` | Queue social metrics sync for bounties |
| `POST` | `/api/cron/bounties/sync-social-metrics` | Sync social metrics for a bounty |
| `POST` | `/api/cron/cleanup/e2e-tests` | Clean up E2E test data |
| `POST` | `/api/cron/cleanup/expired-fraud-groups` | Expire old fraud event groups |
| `POST` | `/api/cron/cleanup/expired-tokens` | Clean up expired tokens |
| `POST` | `/api/cron/cleanup/link-retention` | Clean up links based on retention days |
| `POST` | `/api/cron/cleanup/orphaned` | Clean up orphaned rows |
| `POST` | `/api/cron/cleanup/rejected-applications` | Clean up rejected program applications |
| `POST` | `/api/cron/cleanup/unenrolled-partners` | Clean up unenrolled partners |
| `POST` | `/api/cron/commissions/referrals/backfill` | Backfill referral commissions |
| `POST` | `/api/cron/commissions/referrals/create` | Create referral commission |
| `POST` | `/api/cron/commissions/referrals/queue` | Queue referral commissions for payout |
| `POST` | `/api/cron/discount-codes/create` | Create discount code drain shim ~~deprecated~~ |
| `POST` | `/api/cron/discount-codes/disable` | Disable discount code |
| `POST` | `/api/cron/email-domains/update` | Update Resend domain click tracking |
| `GET` | `/api/cron/email-domains/verify` | Verify email domains status |
| `POST` | `/api/cron/export/commissions` | Process large commission exports |
| `POST` | `/api/cron/export/customers/partner` | Process large partner customer exports |
| `POST` | `/api/cron/export/customers` | Process large customer exports |
| `POST` | `/api/cron/export/events/partner` | Process large partner event exports |
| `GET` | `/api/cron/fraud/summary` | Fraud events summary cron (GET) |
| `POST` | `/api/cron/fraud/summary` | Fraud events summary cron (POST) |
| `POST` | `/api/cron/fx-rates` | Update foreign exchange rates |
| `POST` | `/api/cron/groups/create-default-links` | Create default partner links for a group |
| `POST` | `/api/cron/groups/remap-default-links` | Remap default partner links |
| `POST` | `/api/cron/groups/update-default-links` | Update existing partner links |
| `POST` | `/api/cron/import/bitly` | Import Bitly links cron handler |
### cron
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/cron/bounties/upsert-draft-submissions` | Upsert draft bounty submissions |
| `POST` | `/api/cron/campaigns/broadcast` | Broadcast marketing campaigns |
| `GET` | `/api/cron/campaigns/queue-scheduled` | Queue scheduled campaigns |
| `POST` | `/api/cron/cleanup/declined-invites` | Clean up declined invites |
| `POST` | `/api/cron/cleanup/demo-embed-partners` | Clean up demo embed partners |
| `POST` | `/api/cron/disposable-emails` | Sync disposable and Tremendous prohibited email domain blocklists |
| `GET` | `/api/cron/domains/renewal-payments` | Create payment intents for link domain renewals |
| `GET` | `/api/cron/domains/renewal-reminders` | Send link domain renewal reminders |
| `POST` | `/api/cron/domains/renewal-succeeded` | Renews domains for a given invoice |
| `POST` | `/api/cron/domains/transfer` | Transfer a domain between workspaces |
| `POST` | `/api/cron/domains/update` | Queue or process domain updates for links |
| `GET` | `/api/cron/domains/verify` | Check domain verification statuses |
| `POST` | `/api/cron/export/events/workspace` | Process workspace events export |
| `POST` | `/api/cron/export/links` | Process links export |
| `POST` | `/api/cron/export/partners` | Process partners export |
| `POST` | `/api/cron/export/payouts` | Process payouts export |
| `POST` | `/api/cron/framer/backfill-leads-batch` | Backfill Framer leads batch |
| `POST` | `/api/cron/fraud/release-all-hold-commissions` | Release all hold commissions |
| `POST` | `/api/cron/fraud/release-hold-commissions` | Release hold commissions |
| `POST` | `/api/cron/import/csv` | Import CSV Links |
| `POST` | `/api/cron/import/firstpromoter` | Import FirstPromoter Data |
| `POST` | `/api/cron/import/lemonsqueezy` | Import Lemon Squeezy Data |
| `POST` | `/api/cron/import/partnerstack` | Import PartnerStack Data |
| `POST` | `/api/cron/import/rebrandly` | Import Rebrandly Data |
| `POST` | `/api/cron/import/rewardful` | Import Rewardful Data |
| `POST` | `/api/cron/import/short` | Import Short.io Data |
| `POST` | `/api/cron/import/tapfiliate` | Import data from Tapfiliate |
| `POST` | `/api/cron/import/tolt` | Import data from Tolt |
| `POST` | `/api/cron/invoices/retry-failed` | Retry failed domain renewal invoices |
| `POST` | `/api/cron/links/{linkId}/complete-tests` | Complete A/B tests for a link |
| `POST` | `/api/cron/links/delete` | Delete unclaimed demo links |
| `POST` | `/api/cron/links/invalidate-for-partners` | Invalidate partner link cache |
| `POST` | `/api/cron/messages/notify-partner` | Notify partner of unread messages |
| `POST` | `/api/cron/messages/notify-program` | Notify program users of unread messages |
| `POST` | `/api/cron/network/calculate-program-similarities` | Calculate program similarities in the network |
| `POST` | `/api/cron/partner-platforms` | Update verified partner platforms stats |
### embed
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/embed/referrals/token` | Get referrals embed token |
---
## Technical docs: Partner Search Sync Pipeline
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/how-it-works/partner-search-sync-pipeline
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/(ee)/api/cron/partners/delete/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/delete/route.ts)
- [apps/web/lib/api/links/bulk-delete-links.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/bulk-delete-links.ts)
- [apps/web/lib/api/partners/queue-partner-search-sync.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.co.ts)
- [apps/web/lib/api/partners/search/provider.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/provider.ts)
- [apps/web/lib/api/partners/search/providers/turbopuffer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts)
## Overview
### Overview
This page documents the end-to-end execution flow when a partner deletion cron request (`POST`) triggers side-effect cleanups, unlinking, and eventually interacts with the Turbopuffer search provider via `createNamespace`.
When a partner is permanently deleted from a program, the system must clean up associated database rows, delete related short links in bulk, and queue an index synchronization task with the external vector and search database (Turbopuffer). This flow ensures that search indices remain consistent and reflect deleted enrollments without blocking the primary mutation.
Sources: [apps/web/app/(ee)/api/cron/partners/delete/route.ts:20-184](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/delete/route.ts#L20-L184)
---
### Step 1: POST /api/cron/partners/delete
The entry point is the cron route handler which receives a JSON body containing `workspaceId`, `programId`, `partnerId`, and `userId`. After validating the input schema and ensuring the partner enrollment and its associated links and tags exist, the handler verifies deletion constraints (such as checking for zero active commissions or payouts). It clears related relational records (submitted leads, fraud events, messages) and invokes bulk link deletion if any links are attached to the enrollment.
Sources: [apps/web/app/(ee)/api/cron/partners/delete/route.ts:20-140](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/delete/route.ts#L20-L140)
---
### Step 2: bulkDeleteLinks
Called by the delete route when `links.length > 0`, the `bulkDeleteLinks` function processes links in batches of 100. For each batch, it identifies and removes related discount codes, executes a database transaction to delete the link records, and decrements the project's total link counter. Finally, it schedules asynchronous cleanups for Redis cache entries, Tinybird analytics records, R2 storage images, and queues partner search synchronization if any deleted links were associated with a partner.
Sources: [apps/web/lib/api/links/bulk-delete-links.ts:23-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/bulk-delete-links.ts#L23-L78)
---
### Step 3: queuePartnerSearchSyncForLinks
As part of the asynchronous cleanup following link deletion, `queuePartnerSearchSyncForLinks` maps over the deleted links that carry a `partnerId` and `programId`. It aggregates partner IDs by their respective program IDs to prevent redundant job creation, subsequently delegating each grouped program batch to the search sync queue.
Sources: [apps/web/lib/api/partners/queue-partner-search-sync.ts:100-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.ts#L100-L125)
---
### Step 4: queuePartnerSearchSync
The core queuing function, `queuePartnerSearchSync`, validates that a search provider is actively configured. It chunks enrollment IDs and partner IDs into batch payloads and attempts to dispatch them to the `partnerSearchSyncJob` queue with a 5-second default delay. This delay ensures database mutations have fully committed before background workers attempt to read the rows back.
Sources: [apps/web/lib/api/partners/queue-partner-search-sync.ts:44-83](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.ts#L44-L83)
---
### Step 5: getPartnerSearchProvider
To interact with the search service, `getPartnerSearchProvider` checks for the presence of the `TURBOPUFFER_API_KEY` environment variable. If configured, it initializes and caches a singleton instance of the Turbopuffer search provider, avoiding repeated TLS handshakes and connection pool overhead across requests.
Sources: [apps/web/lib/api/partners/search/provider.ts:12-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/provider.ts#L12-L20)
---
### Step 6: createTurbopufferPartnerSearchProvider
The provider factory initializes the Turbopuffer integration wrapper. It exposes methods for searching candidates, counting results, upserting documents, and removing document IDs. Behind the scenes, these methods reference a shared vector namespace (defaults to `partner-search-v4`) where partner program data is indexed.
Sources: [apps/web/lib/api/partners/search/providers/turbopuffer.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L34-L36), [apps/web/lib/api/partners/search/providers/turbopuffer.ts:345-421](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L345-L421)
---
### Step 7: createNamespace
When provider operations require access to Turbopuffer, `createNamespace` instantiates the official `@turbopuffer/turbopuffer` client configured with the API key and the `aws-us-east-1` region. It returns the requested namespace object (e.g., `partner-search-v4`), allowing write, query, and delete operations to execute against the remote index.
Sources: [apps/web/lib/api/partners/search/providers/turbopuffer.ts:123-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L123-L136)
---
## Sequence Diagram
```mermaid
sequenceDiagram
participant Cron as POST /api/cron/partners/delete
participant Links as bulkDeleteLinks
participant QueueLinks as queuePartnerSearchSyncForLinks
participant Queue as queuePartnerSearchSync
participant Provider as getPartnerSearchProvider
participant Turbopuffer as createTurbopufferPartnerSearchProvider
participant Namespace as createNamespace
Cron->>Cron: Parse input, validate constraints & delete DB records
alt Links exist
Cron->>Links: bulkDeleteLinks(links)
Links->>Links: Delete discount codes & execute transaction
Links->>QueueLinks: queuePartnerSearchSyncForLinks(links)
QueueLinks->>Queue: queuePartnerSearchSync({ partnerIds, programId })
end
Cron->>Queue: queuePartnerSearchSync({ enrollmentIds })
Queue->>Provider: getPartnerSearchProvider()
Provider->>Turbopuffer: createTurbopufferPartnerSearchProvider()
Turbopuffer->>Namespace: createNamespace(resolvedNamespaceName)
Namespace-->>Turbopuffer: TurbopufferNamespace instance
Turbopuffer-->>Provider: SearchProvider methods
Provider-->>Queue: Provider ready for sync/write
```
Sources: [apps/web/app/(ee)/api/cron/partners/delete/route.ts:20-163](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/delete/route.ts#L20-L163), [apps/web/lib/api/links/bulk-delete-links.ts:23-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/bulk-delete-links.ts#L23-L73), [apps/web/lib/api/partners/queue-partner-search-sync.ts:44-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.ts#L44-L125), [apps/web/lib/api/partners/search/provider.ts:12-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/provider.ts#L12-L20), [apps/web/lib/api/partners/search/providers/turbopuffer.ts:123-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L123-L136), [apps/web/lib/api/partners/search/providers/turbopuffer.ts:345-351](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L345-L351)
---
## Flowchart
```mermaid
flowchart TD
A[POST Cron Request] --> B{Enrollment Exists?}
B -- No --> C[Skip Delete & Respond]
B -- Yes --> D{Can Delete Partner?}
D -- No --> E[Reject & Respond]
D -- Yes --> F[Delete Relational Records]
F --> G{Links Exist?}
G -- Yes --> H[bulkDeleteLinks]
H --> I[Queue Link Search Sync]
G -- No --> J[Delete Enrollment & Project Usage]
I --> J
J --> K[queuePartnerSearchSync]
K --> L{Provider Configured?}
L -- No --> M[No-op / Skip Indexing]
L -- Yes --> N[getPartnerSearchProvider]
N --> O[createTurbopufferPartnerSearchProvider]
O --> P[createNamespace]
P --> Q[Dispatch Sync Job to QStash]
```
Sources: [apps/web/app/(ee)/api/cron/partners/delete/route.ts:49-163](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/delete/route.ts#L49-L163), [apps/web/lib/api/links/bulk-delete-links.ts:31-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/bulk-delete-links.ts#L31-L73), [apps/web/lib/api/partners/queue-partner-search-sync.ts:52-83](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.ts#L52-L83), [apps/web/lib/api/partners/search/provider.ts:12-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/provider.ts#L12-L20), [apps/web/lib/api/partners/search/providers/turbopuffer.ts:123-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/providers/turbopuffer.ts#L123-L136)
---
## Key Observations
- **Cross-Module Boundaries:** This execution flow bridges administrative cron jobs, relational database persistence (Prisma), bulk link deletion side effects (Redis, Tinybird, R2 storage), and external vector search index synchronization (Turbopuffer).
- **Resilience and Non-blocking Queues:** The partner search queue wrapper (`queuePartnerSearchSync`) is designed to catch dispatch failures without failing the primary source mutation. If QStash dispatch throws, errors are logged gracefully while allowing the core HTTP response to proceed.
- **Singleton Caching:** The search provider is initialized as a process-level singleton (`cachedSearchProvider`), which prevents connection overhead and TLS handshake degradation during frequent search and synchronization updates.
Sources: [apps/web/lib/api/links/bulk-delete-links.ts:48-72](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/bulk-delete-links.ts#L48-L72), [apps/web/lib/api/partners/queue-partner-search-sync.ts:40-83](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/queue-partner-search-sync.ts#L40-L83), [apps/web/lib/api/partners/search/provider.ts:4-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/search/provider.ts#L4-L20)
---
## Technical docs: GET Get analytics for admin
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getadminanalytics
## Responses
## Try It
---
## Technical docs: Overview
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/getting-started/overview
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/lib/openapi/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/page.tsx)
- [apps/web/ui/placeholders/features-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/features-section.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/app/ee/app.dub.co/new-program/slug/program/new/overview/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/(new-program)/%5Bslug%5D/program/new/overview/page.tsx)
- [apps/web/app/domain/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/welcome/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/welcome/page.tsx)
- [packages/hubspot-app/hsproject.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/hsproject.json)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/products/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/products/page.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/plan/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/plan/page.tsx)
- [packages/ui/src/content.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts)
- [packages/cli/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts)
- [packages/ui/src/nav/content/product-content.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/content/product-content.tsx)
- [packages/ui/src/footer.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/footer.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/page.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx)
- [apps/web/app/ee/app.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/layout.tsx)
- [apps/web/app/app.dub.co/dashboard/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/layout.tsx)
- [packages/cli/src/types/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts)
- [packages/stripe-app/src/utils/constants.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts)
- [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)
- [packages/utils/src/constants/main.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts)
- [packages/embeds/core/src/embed.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/embed.ts)
- [apps/web/app/app.dub.co/marketplace/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx)
- [packages/ui/src/nav/content/resources-content.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/content/resources-content.tsx)
- [apps/web/app/app.dub.co/auth-marketing/register/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth-marketing)/register/page.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/layout.tsx)
- [packages/email/src/templates/welcome-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/welcome-email.tsx)
- [packages/utils/src/constants/pricing/pricing-plan-compare-features.tsx](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/pricing/pricing-plan-compare-features.tsx)
## Overview
Dub is the modern link attribution platform engineered for short links, real-time conversion tracking, and scalable affiliate programs. This system overview explores the underlying architecture and developer toolchains powering Dub, encompassing its monorepo layout, OpenAPI-driven REST interface, custom domain infrastructure, onboarding pathways, enterprise partner programs, and extensible third-party ecosystem integrations.
Sources: [apps/web/lib/openapi/index.ts:28-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L28-L32)
## Platform Architecture and Monorepo Layout
### Overview
Dub is structured as a robust monorepo organizing distinct application runtimes, shared component libraries, utility modules, and template rendering engines. The codebase divides responsibilities across specialized packages and Next.js applications, governing hostnames, environment bindings, SDK integrations, and transactional messaging.
Sources: [apps/web/lib/dub.ts:1-3](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts#L1-L3), [packages/utils/src/constants/main.ts:1-58](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts#L1-L58)
### Core System Hostnames and Environments
The infrastructure relies on explicit environment variables and runtime checks to partition API routing, partner portals, and core application domains across production, staging, and local development environments.
| Constant Name | Production / Staging Value | Local / Fallback Value | Purpose |
| :--- | :--- | :--- | :--- |
| `API_DOMAIN` | `https://api.dub.co` / `https://api-staging.dub.co` | `http://api.localhost:8888` | Base URL for REST API endpoints |
| `PARTNERS_DOMAIN` | `https://partners.dub.co` / `https://partners-staging.dub.co` | `http://partners.localhost:8888` | Base URL for the partner program portal |
| `APP_DOMAIN` | `https://app.dub.co` / `https://${NEXT_PUBLIC_VERCEL_URL}` | `http://localhost:8888` | Base URL for the primary Next.js web application |
| `SHORT_DOMAIN` | `dub.sh` | `dub.sh` | Default short link domain suffix |
Sources: [packages/utils/src/constants/main.ts:1-58](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts#L1-L58)
> [!NOTE]
> Preview environments utilize dynamic Vercel URL patterns for app hostnames, whereas preview API and partner domains rely explicitly on their respective staging subdomains (`api-staging.dub.co` and `partners-staging.dub.co`).
Sources: [packages/utils/src/constants/main.ts:11-58](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts#L11-L58)
### Shared UI Packages and Navigation
The user interface layer (`packages/ui`) exports consolidated navigation taxonomies, layout wrappers, and design primitives utilized across web views. This includes product feature arrays, legal document lists, and official SDK metadata.
```typescript
export const SDKS = [
{ icon: Typescript, href: "/sdks/typescript", title: "Typescript" },
{ icon: Python, href: "/sdks/python", title: "Python" },
{ icon: Go, href: "/sdks/go", title: "Go" },
{ icon: Ruby, href: "/sdks/ruby", title: "Ruby" },
{ icon: Php, href: "/sdks/php", title: "PHP" },
];
```
Sources: [packages/ui/src/content.ts:81-115](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts#L81-L115)
The platform UI also maintains strict categorization for product solutions, secondary resource directories, and social handles.
| Category | Representative Items | Target Slugs / URLs |
| :--- | :--- | :--- |
| **Features List** | Partners, Analytics, Links, API, Integrations | `/partners`, `/analytics`, `/links`, `/integrations` |
| **Solutions** | Affiliate Management, Marketing Attribution, Creators | `/partners`, `/analytics`, `/solutions/creators` |
| **Legal Pages** | Privacy Policy, Terms of Service, SLA, DPA | `privacy`, `terms`, `sla`, `dpa` |
| **Social Channels** | X (Twitter), LinkedIn, GitHub, YouTube | External platform links |
Sources: [packages/ui/src/content.ts:44-79](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts#L44-L79), [packages/ui/src/content.ts:117-140](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts#L117-L140), [packages/ui/src/content.ts:206-234](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts#L206-L234)
### Email Template Architecture
The email package (`packages/email`) builds responsive transactional notifications utilizing `@react-email/components` and Tailwind CSS. The `WelcomeEmail` workflow handles customer onboarding messages by conditionally parsing workspace slugs, logos, and onboarding step links.
```typescript
export default function WelcomeEmail({
email = "panic@thedis.co",
workspace,
unsubscribeUrl,
}: WelcomeEmailProps) {
const workspaceUrl = workspace
? `https://app.dub.co/${workspace?.slug}`
: "https://app.dub.co";
// Renders container, branding, workspace card, and getting started checklist
}
```
Sources: [packages/email/src/templates/welcome-email.tsx:20-36](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/welcome-email.tsx#L20-L36)
## Public APIs and Developer Tools
### OpenAPI Specification and REST Architecture
The Dub platform exposes its programmatic interface via an OpenAPI-driven REST specification constructed using `zod-openapi` within `apps/web/lib/openapi/index.ts`. The OpenAPI document is configured with version `0.0.1`, metadata identifying the API as the **Dub API**, and contact/license references.
```typescript
export const document = createDocument({
openapi: "3.0.3",
info: {
title: "Dub API",
description:
"Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs.",
version: "0.0.1",
contact: {
name: "Dub Support",
email: "support@dub.co",
url: "https://dub.co/support",
},
license: {
name: "AGPL-3.0 license",
url: "https://github.com/dubinc/dub/blob/main/LICENSE.md",
},
},
servers: [
{
url: "https://api.dub.co",
description: "Production API",
},
],
// Paths and components configuration...
});
```
Sources: [apps/web/lib/openapi/index.ts:26-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L26-L48)
The specification aggregates routing modules across core domain endpoints and registers standard schema components and security mechanisms.
| Component Category | Registered Schema / Scheme Entries | Source Reference |
| :--- | :--- | :--- |
| **Component Schemas** | `LinkSchema`, `LinkTagSchema`, `FolderSchema`, `DomainSchema`, `DiscountCodeSchema`, `webhookEventSchema`, `LinkErrorSchema` | [apps/web/lib/openapi/index.ts:68-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L68-L76) |
| **Security Schemes** | `token` (HTTP Bearer authentication with `x-speakeasy-example: DUB_API_KEY`) | [apps/web/lib/openapi/index.ts:77-84](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L77-L84) |
| **Error Responses** | Standard OpenAPI error response components (`openApiErrorResponsesComponents`) | [apps/web/lib/openapi/index.ts:85-87](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L85-L87) |
Sources: [apps/web/lib/openapi/index.ts:67-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L67-L88)
The API paths integrated into the document cover all platform subsystems, combining link management, analytics, tracking, customer attribution, partners, payout systems, and embed tokens.
| Path Module Group | Included Path Imports |
| :--- | :--- |
| **Core Links & Organization** | `linksPaths`, `analyticsPath`, `eventsPath`, `tagsPaths`, `foldersPaths`, `domainsPaths` |
| **Attribution & Tracking** | `trackPaths`, `customersPaths`, `partnersPaths`, `programApplicationsPaths`, `discountCodesPaths`, `commissionsPaths`, `payoutsPaths` |
| **Embeds & Tools** | `embedTokensPaths`, `qrCodePaths`, `bountiesPaths` |
Sources: [apps/web/lib/openapi/index.ts:50-66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L50-L66)
> [!NOTE]
> The OpenAPI document utilizes Speakeasy annotations (`x-speakeasy-example`) within security scheme definitions to automatically generate strongly-typed public SDKs across multiple languages.
Sources: [apps/web/lib/openapi/index.ts:77-84](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/index.ts#L77-L84)
### Server-Side SDK Integration
Internal web applications interact with the public API using the official `dub` Node.js client package. For instance, customer record retrieval initializes a client instance and queries customer entities filtering by external database identifiers.
```typescript
import { Dub } from "dub";
export const dub = new Dub();
// fetch Dub customer using their external ID (ID in our database)
export const getDubCustomer = async (userId: string) => {
try {
const { result: customers } = await dub.customers.list({
externalId: userId,
includeExpandedFields: true,
});
return customers.length > 0 ? customers[0] : null;
} catch (error) {
console.error(error);
return null;
}
};
```
Sources: [apps/web/lib/dub.ts:1-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts#L1-L18)
### CLI Management Utilities
Management operations can also be executed via the official command-line interface package (`packages/cli`), which is built on top of the `commander` library. Process signal handlers (`SIGINT` and `SIGTERM`) ensure clean exits.
```typescript
#!/usr/bin/env node
import { config } from "@/commands/config";
import { domains } from "@/commands/domains";
import { login } from "@/commands/login";
import { shorten } from "@/commands/shorten";
import { getPackageInfo } from "@/utils/get-package-info";
import { Command } from "commander";
import { links } from "./commands/links";
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
async function main() {
const packageInfo = await getPackageInfo();
const program = new Command()
.name("dub")
.description("A CLI for shortening links with the Dub API.")
.version(
packageInfo.version || "1.0.0",
"-v, --version",
"display the version number",
);
program
.addCommand(login)
.addCommand(config)
.addCommand(domains)
.addCommand(shorten)
.addCommand(links);
program.parse();
}
main();
```
Sources: [packages/cli/src/index.ts:1-36](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L1-L36)
The CLI configuration and error contracts are typed via dedicated TypeScript interfaces governing local credential persistence and structured API error responses.
| Interface Name | Properties | Purpose |
| :--- | :--- | :--- |
| `DubConfig` | `access_token` (string), `refresh_token` (string \| null), `expires_at` (number \| null), `domain` (optional string) | Manages local CLI authentication state and active domain context |
| `APIError` | `error: { code: string; message: string; doc_url: string }` | Standardized payload structure returned upon encountering API faults |
Sources: [packages/cli/src/types/index.ts:1-14](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L1-L14)
## Link Infrastructure and Deep Linking
### Overview
Link infrastructure and deep linking form the core routing mechanisms that translate short URLs, custom domains, and mobile intents into precise destinations. The platform combines dynamic domain welcome pages, feature placeholders, and intelligent deep link preview resolution to handle both web navigation and mobile app deep linking.
Sources: [apps/web/ui/placeholders/features-section.tsx:15-120](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/features-section.tsx#L15-L120), [apps/web/app/domain/page.tsx:1-86](https://github.com/blade47/dub/blob/HEAD/apps/web/app/domain/page.tsx#L1-L86), [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:45-255](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx#L45-L255)
### Custom Domain Routing and Placeholder Fallbacks
When visitors access a custom domain root, the application renders a specialized welcome page (`CustomDomainPage`) configured with custom metadata derived from the domain name parameter. The page caches responses indefinitely via `revalidate = false` and serves static parameters via `generateStaticParams()`.
```typescript
export const revalidate = false; // cache indefinitely
export async function generateMetadata(props: {
params: Promise<{ domain: string }>;
}) {
const params = await props.params;
const title = `${params.domain.toUpperCase()} - A Dub Custom Domain`;
const description = `${params.domain.toUpperCase()} is a custom domain on Dub - the modern link attribution platform for short links, conversion tracking, and affiliate programs.`;
return constructMetadata({
title,
description,
});
}
export function generateStaticParams() {
return [];
}
```
Sources: [apps/web/app/domain/page.tsx:11-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/domain/page.tsx#L11-L28)
The custom domain layout embeds a feature showcase section (`FeaturesSection`) that dynamically formats feature cards with marketing utilities. Each card generates tracking-enabled hyperlinks via `createHref` utilizing UTM parameters (`utm_source: "Custom Domain"`, `utm_medium: "Welcome Page"`).
| Feature Card Title | Description Summary | Link Action / Destination |
| :--- | :--- | :--- |
| **Stand out with custom domains** | Create branded short links with your own domain and improve click-through rates. | Learn more (`/help/article/how-to-add-custom-domain`) |
| **Branded QR codes** | Free QR codes for every short link with custom logo support. | Try the demo (`/tools/qr-code`) |
| **Analytics that matter** | Geolocation, device, browser, and referrer metrics. | Explore analytics (`/help/article/dub-analytics`) |
| **Advanced link features** | Custom previews, device/geo targeting, link cloaking, and password protection. | Learn more (`/help/article/how-to-create-link`) |
| **Collaborate with your team** | Teammate collaboration and SAML SSO (Okta, Google, Azure AD). | Learn more (`/help/article/how-to-invite-teammates`) |
Sources: [apps/web/app/domain/page.tsx:30-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/domain/page.tsx#L30-L84), [apps/web/ui/placeholders/features-section.tsx:37-116](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/features-section.tsx#L37-L116)
> [!NOTE]
> The `FeaturesSection` client component extracts the active domain using Next.js `useParams()` and wraps feature descriptions in a Markdown renderer that intercepts anchor tags to open them in external browser contexts.
Sources: [apps/web/ui/placeholders/features-section.tsx:15-20](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/features-section.tsx#L15-L20), [apps/web/ui/placeholders/features-section.tsx:161-176](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/features-section.tsx#L161-L176)
### Deep Link Resolution Mechanisms
The deep linking subsystem (`DeepLinkPreviewPage`) processes incoming requests across arbitrary domains and optional key parameters (`[[...key]]`), orchestrating mobile platform detection and database validation.
```typescript
export default async function DeepLinkPreviewPage(props: {
params: Promise<{ domain: string; key?: string[] }>;
}) {
const params = await props.params;
const domain = params.domain;
const key = params.key ? decodeURIComponent(params.key.join("/")) : "_root";
// Detect language from Accept-Language header
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
const language = getLanguage(acceptLanguage);
const t = getTranslations(language);
const ua = userAgent({ headers: headersList });
const platform: "ios" | "android" =
ua.os?.name === "Android" ? "android" : "ios";
// Encode the key for case-sensitive domains before querying
const encodedKey = encodeKeyIfCaseSensitive({
domain,
key,
});
let link = await prisma.link.findUnique({
where: {
domain_key: {
domain,
key: encodedKey,
},
},
select: {
domain: true,
key: true,
shortLink: true,
url: true,
ios: true,
android: true,
shortDomain: {
select: {
appleAppSiteAssociation: true,
assetLinks: true,
deepviewData: true,
},
},
},
});
// if the link doesn't exist, we redirect to the root domain link
if (!link) {
redirect(`https://${domain}`);
}
...
```
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:45-95](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx#L45-L95)
Once the database record is retrieved, platform-specific association checks determine whether to render the deep link preview UI or perform an immediate redirection.
```typescript
const { appleAppSiteAssociation, assetLinks, deepviewData } =
link.shortDomain;
// if the domain isn't set up for deep linking on the user's platform, skip
// the preview and forward to the platform-specific URL (or the canonical URL)
if (platform === "android") {
if (!assetLinks || !deepviewData) {
redirect(link.android ?? link.url);
}
} else {
if (!appleAppSiteAssociation || !deepviewData) {
redirect(link.ios ?? link.url);
}
}
const deepViewData = parseDeepViewData(deepviewData);
// decode the link if the domain is case sensitive
link = decodeLinkIfCaseSensitive(link);
// This should never happen
if (!link) {
redirect(`https://${domain}`);
}
```
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:97-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx#L97-L120)
> [!WARNING]
> If a short link record cannot be found in the database during deep link resolution, the handler immediately terminates execution and issues an HTTP redirect back to the root domain (`https://${domain}`).
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:92-95](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx#L92-L95)
## Workspace Onboarding and Plan Tiers
### Overview
Account registration and workspace setup begin through the authentication marketing registration page (`RegisterPage`), which wraps `RegisterPageClient` inside an `AuthLayout` configured with `showTerms="app"`.
Sources: [apps/web/app/app.dub.co/auth-marketing/register/page.tsx:10-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/auth-marketing/register/page.tsx#L10-L16)
Once registered, users enter the multi-step onboarding journey governed by the layout component in `apps/web/app/app.dub.co/onboarding/onboarding/steps/layout.tsx`. This layout renders an absolute background featuring a 60px-cell grid (`Grid`) and an `AuroraGradient`, centered with a `Wordmark` pointing to `https://dub.co/home`, and a mobile `SignedInHint` component.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/layout.tsx:8-53](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/layout.tsx#L8-L53)
The onboarding flow progresses through discrete steps:
1. **Welcome (`/welcome`)**: Invokes `TrackSignup` and renders `AccountTypeSelector` inside `StepPage` with test ID `testIds.onboarding.stepWelcome` and a maximum width of `640px`.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/welcome/page.tsx:6-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/welcome/page.tsx#L6-L20)
2. **Products (`/products`)**: Renders `ProductSelector` within `StepPage` (`testIds.onboarding.stepProducts`), asking users what they want to do with Dub across an unconstrained width (`max-w-none`).
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/products/page.tsx:5-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/products/page.tsx#L5-L16)
3. **Plan (`/plan`)**: Dynamically adjusts its title and description based on the active product retrieved via `useOnboardingProduct()`. It provisions plan choices via `PlanSelector` and offers an enterprise link, a free plan button, and a product-specific pricing comparison link.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/plan/page.tsx:13-78](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/plan/page.tsx#L13-L78)
4. **Success (`/success`)**: Concludes onboarding by rendering workspace settings shortcuts, an optional Slack support invitation flow via `SlackSupportInviteModal`, and navigational links to team management, the help center, documentation, and support chat.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx:273-382](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx#L273-L382)
> [!NOTE]
> During the onboarding plan step (`/plan`), if the selected product is set to `"links"`, the UI renders a `LaterButton` that allows users to defer plan selection and jump straight to the `"success"` step with the free tier.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/plan/page.tsx:58-66](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/onboarding/onboarding/steps/plan/page.tsx#L58-L66)
### Feature Tier Provisioning
Feature availability across subscription tiers is defined in `PRICING_PLAN_COMPARE_FEATURES`. The structure organizes limits, boolean checks, and text renderers across categories such as Links, Partners, Analytics, Domains, API, Workspace, and Support.
| Category | Feature Item | Free Tier Access | Pro Tier Access | Business Tier Access | Advanced Tier Access | Enterprise Tier Access |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **Links** | Folders | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Custom link previews | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Deep links | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Link cloaking | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Link expiration | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Password protection | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Device targeting | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | Geo targeting | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Links** | A/B testing | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Automated global payouts | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Tax compliance | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Dual-sided incentives | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | AI landing page generator | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Embedded referral dashboard | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Messaging center | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Email campaigns | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) |
| **Partners** | Partner network access | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) |
| **Analytics** | Conversion tracking | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Analytics** | Customer insights | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Analytics** | Real-time events stream | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Domains** | Free `.link` domain | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **API** | Event webhooks | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Workspace** | Role-based access control | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) | Enabled (`true`) | Enabled (`true`) |
| **Workspace** | SAML/SSO | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) |
| **Workspace** | Audit logs | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) |
| **Support** | Dedicated success manager | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Restricted (`false`) | Enabled (`true`) |
Sources: [packages/utils/src/constants/pricing/pricing-plan-compare-features.tsx:11-612](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/pricing/pricing-plan-compare-features.tsx#L11-L612)
## Partner Programs and Embeddable Systems
### Overview
Enterprise partner program management and embeddable client systems provide infrastructure for scaling referral networks. The Dub platform coordinates partner analytics, creation flows, and browser-side embedding across distributed web properties.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/page.tsx:5-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/dashboard/slug/ee/program/page.tsx#L5-L20), [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:22-209](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx#L22-L209)
### Embed Client Integration
The embed client initializes global browser hooks by attaching a `Dub` interface to the `window` object when executed in browser environments. This system powers embedded partner portals such as quickstart links, resource management, and payout connections.
```typescript
import { init } from "./core";
import { DubEmbed } from "./types";
declare global {
interface Window {
Dub: DubEmbed;
}
}
if (typeof window !== "undefined") {
window.Dub = (window.Dub || {}) as DubEmbed;
window.Dub.init = init;
}
```
Sources: [packages/embeds/core/src/embed.ts:1-14](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/embed.ts#L1-L14)
### Referral Quickstart Actions
The referral quickstart component structures partner onboarding flows into discrete actionable blocks including link sharing, resource downloads, and payout configurations.
| Action Item | Target Tab / Destination | Prerequisite Condition | CTA State / Behavior |
| :--- | :--- | :--- | :--- |
| **Share your link** | `"Links"` tab | `links.length > 0` | Copies constructed partner link or navigates to link creation. |
| **Program resources** | `"Resources"` tab | `hasResources: boolean` | Disables button if no resource assets are attached (`!hasResources`). |
| **Browse the FAQ** | `"FAQ"` tab | `programEmbedData?.hideEarnings` | Opens FAQ accordion list when earnings are hidden. |
| **Receive earnings** | `"Settings"` tab or external URL | `earnings.upcoming === 0 && earnings.paid === 0` | Routes to Tremendous payout settings or external portal based on country support and default method. |
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:22-160](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx#L22-L160)
> [!NOTE]
> The Payout configuration step dynamically checks `TREMENDOUS_SUPPORTED_COUNTRIES` against the partner's registered country profile to determine if internal payout settings should render or if redirection to external partners is required.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:138-154](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx#L138-L154)
## Ecosystem Integrations and Extensibility
### Ecosystem Integrations and Extensibility
Third-party application integration, CRM connectors, and embedded marketplace layouts extend Dub's core link management capabilities into broader enterprise ecosystems. The platform coordinates integrations through dedicated packages, client configuration parameters, and layout structures.
Sources: [packages/hubspot-app/hsproject.json:1-5](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/hsproject.json#L1-L5), [packages/stripe-app/src/utils/constants.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L1-L6), [apps/web/app/app.dub.co/marketplace/layout.tsx:1-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx#L1-L19)
### CRM and Third-Party Connectors
Dub packages integrations with external platforms such as HubSpot and Stripe, providing standardized identifiers and environment properties for application exchange. The HubSpot project configuration establishes project names and platform execution versions, while Stripe application constants define client identifiers and host endpoints.
```json
{
"name": "Dub",
"srcDir": "src",
"platformVersion": "2025.2"
}
```
Sources: [packages/hubspot-app/hsproject.json:1-5](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/hsproject.json#L1-L5), [packages/stripe-app/src/utils/constants.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L1-L6)
| Integration Constant | Value / Identifier | Target Purpose |
| :--- | :--- | :--- |
| **`DUB_CLIENT_ID`** | `"dub_app_517290377fe6b4dfcc8726a7061ba9b6da1c4d7d7d75f77a"` | OAuth and application authentication for Stripe app integration. |
| **`DUB_HOST`** | `"https://app.dub.co"` | Primary web application host URL for redirect and routing flows. |
| **`DUB_API_HOST`** | `"https://api.dub.co"` | Base API routing endpoint for programmatic third-party requests. |
| **`platformVersion`** | `"2025.2"` | HubSpot project execution environment specification. |
Sources: [packages/hubspot-app/hsproject.json:1-5](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/hsproject.json#L1-L5), [packages/stripe-app/src/utils/constants.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L1-L6)
### Embedded Marketplace Navigation
The external marketplace layout renders responsive integration directories with custom grid lines, header components, and integrated footers. Navigation resources organize documentation, company profiles, and updates into categorized resource columns.
```typescript
const COLUMNS = [
{
heading: "Help and Support",
titles: ["Docs", "Help Center", "Contact"],
},
{
heading: "Company",
titles: ["About", "Careers", "Dub Brand"],
},
{
heading: "Updates",
titles: ["Blog", "Changelog"],
},
];
```
Sources: [apps/web/app/app.dub.co/marketplace/layout.tsx:1-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx#L1-L34), [packages/ui/src/nav/content/resources-content.tsx:10-23](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/content/resources-content.tsx#L10-L23)
> [!TIP]
> The marketplace external layout includes `MarketplaceExternalGridLines` which draws fixed vertical border dividers constrained to a maximum container width of `max-w-screen-xl` with gradient masks starting at 96px.
Sources: [apps/web/app/app.dub.co/marketplace/layout.tsx:21-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx#L21-L33)
## Related
- [[Quick Start]]
- [[Project Structure]]
- [[Routing and Multitenancy]]
---
## Technical docs: POST Ban a user and their associated workspaces
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/banuserandworkspaces
## Request Body
Email and blocking options
## Responses
## Try It
---
## Technical docs: Quick Start
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/getting-started/quick-start
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/scripts/dev/seed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts)
- [apps/web/docker-compose.yml](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml)
- [apps/web/scripts/dev/seed-100k-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts)
- [apps/web/scripts/dev/seed-integration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-integration.ts)
- [apps/web/scripts/dev/seed-application-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-application-events.ts)
- [apps/web/scripts/dev/seed-partner-enrollment.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/scripts/dev/seed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-commissions.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/scripts/dev/test-partner-referrals.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/test-partner-referrals.ts)
- [apps/web/scripts/customers/annature/import-domains.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/annature/import-domains.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/installation-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/installation-section.tsx)
- [apps/web/scripts/dev/debug-partner-search.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/debug-partner-search.ts)
- [apps/web/scripts/migrations/backfill-application-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-application-events.ts)
- [apps/web/scripts/dev/upsert-emoji-embeddings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/upsert-emoji-embeddings.ts)
- [apps/web/scripts/partners/aggregate-stats-seeding.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/partners/aggregate-stats-seeding.ts)
- [apps/web/scripts/dev/benchmark-partner-search.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts)
- [packages/stripe-app/stripe-app.dev.json](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.dev.json)
- [apps/web/package.json](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json)
- [packages/utils/src/constants/localhost.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/localhost.ts)
- [apps/web/scripts/create-integration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts)
- [apps/web/scripts/dub-wrapped.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-wrapped.ts)
- [apps/web/scripts/restore-banned-workspace.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/restore-banned-workspace.ts)
- [apps/web/scripts/partners/backfill-partner-search.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/partners/backfill-partner-search.ts)
- [apps/web/scripts/misc/trigger-sync-embeddings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/misc/trigger-sync-embeddings.ts)
- [apps/web/playwright.config.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/playwright.config.ts)
- [apps/web/scripts/migrations/backfill-partner-usernames.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-partner-usernames.ts)
- [turbo.json](https://github.com/blade47/dub/blob/HEAD/turbo.json)
- [apps/web/scripts/programs/bulk-star-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/bulk-star-partners.ts)
## Overview
The quick start guide establishes the local development environment and database infrastructure required to run the Dub monorepo stack. It covers workspace dependency management, Turborepo build orchestration, Docker Compose service configuration, and various database initialization and seeding workflows. Developers can provision core fixtures, generate mock partner accounts, commission records, and referral application events, configure third-party integration secrets, and populate large-volume datasets for search indexing, vector embedding, and performance benchmarking. Sources: [apps/web/docker-compose.yml:1-46](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml#L1-L46), [apps/web/scripts/dev/seed-100k-partners.ts:1-27](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts#L1-L27), [apps/web/scripts/dev/seed.ts:1-241](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L1-L241), [apps/web/scripts/dev/benchmark-partner-search.ts:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L1-L26), [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
## Prerequisites and Monorepo Workspace Setup
### Overview
The Dub repository operates as a monorepo utilizing Turborepo build orchestration to manage package pipelines and task dependencies. Workspace package management relies on `pnpm` workspaces, linking internal packages such as `@dub/email`, `@dub/embed-react`, `@dub/tailwind-config`, `@dub/ui`, and `@dub/utils` directly into the web application workspace. Turborepo coordinates pipeline execution through `turbo.json`, enforcing build and test dependencies across workspace packages while maintaining caching and persistent service processes. Sources: [apps/web/package.json:1-184](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json#L1-L184), [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
### Turborepo Build Pipeline Orchestration
Turborepo reads configuration from `turbo.json` to schedule execution tasks across the workspace. The build pipeline defines explicit task dependencies and output boundaries, treating global environment files (`**/.env`) as global dependencies that invalidate cache states when modified. Sources: [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
| Pipeline Task | Task Dependencies (`dependsOn`) | Persistent (`persistent`) | Cache Enabled (`cache`) | Output Paths / Behavior (`outputs`) |
| --- | --- | --- | --- | --- |
| `build` | `["^build"]` | *false* (default) | *true* (default) | `["!.next/cache/**", ".next/**", "dist/**"]` |
| `dev` | None | `true` | `false` | None (persistent server process) |
| `clean` | None | *false* | `false` | None (removes build artifacts) |
| `test` | `["^build"]` | *false* (default) | *true* (default) | None |
Sources: [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
### Web Application Package Scripts
The `apps/web` package defines scripts in `package.json` for development execution, database schema generation, testing, and OpenAPI spec generation. These scripts integrate Prisma client generation workflows directly prior to compilation, test execution, or server startup. Sources: [apps/web/package.json:5-20](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json#L5-L20)
| Script Name | Command Execution String | Purpose |
| --- | --- | --- |
| `dev` | `pnpm prisma:generate && concurrently --kill-others "next dev --turbopack --port 8888"` | Generates Prisma client and starts Next.js dev server with Turbopack on port 8888 |
| `build` | `pnpm prisma:generate && next build` | Generates Prisma client and runs production Next.js build |
| `lint` | `next lint` | Executes Next.js linter checks |
| `start` | `next start` | Starts production Next.js server instance |
| `script` | `tsx ./scripts/run.ts` | Executes arbitrary TypeScript scripts via `tsx` |
| `test` | `pnpm prisma:generate && vitest -no-file-parallelism --bail=1` | Runs Vitest test suite with zero file parallelism and fails on first error |
| `test:e2e` | `playwright test` | Executes Playwright end-to-end tests |
| `test:e2e:ui` | `playwright test --ui` | Opens Playwright UI runner for end-to-end tests |
| `test:e2e:headed` | `playwright test --headed` | Runs Playwright end-to-end tests in headed browser mode |
| `generate-openapi` | `tsx ./scripts/generate-openapi.ts` | Generates OpenAPI specification artifacts |
| `prisma:generate` | `dotenv-flow -e .env -- prisma generate --schema=./prisma/schema` | Generates Prisma client from schema using environment variables |
| `prisma:push` | `dotenv-flow -e .env -- prisma db push --schema=./prisma/schema` | Pushes Prisma schema state directly to database without migrations |
| `prisma:studio` | `dotenv-flow -e .env -- prisma studio --schema=./prisma/schema --browser none` | Launches Prisma Studio GUI without spawning an automatic browser window |
| `prisma:format` | `dotenv-flow -e .env -- prisma format --schema=./prisma/schema` | Formats Prisma schema files |
Sources: [apps/web/package.json:5-20](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json#L5-L20)
> [!NOTE]
> Workspace packages such as `@dub/email`, `@dub/embed-react`, `@dub/tailwind-config`, `@dub/ui`, and `@dub/utils` use workspace protocol specifiers (`workspace:*`), forcing `pnpm` to link local workspace directories rather than fetching from registry endpoints during monorepo builds. Sources: [apps/web/package.json:33-37](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json#L33-L37)
### Localhost Constants and Network Fallbacks
The monorepo defines baseline constants for local execution fallbacks, including geographical coordinates and IP address defaults used during local analytics ingestion or testing. Sources: [packages/utils/src/constants/localhost.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/localhost.ts#L1-L10)
```typescript
export const LOCALHOST_GEO_DATA = {
continent: "NA",
country: "US",
city: "San Francisco",
region: "CA",
latitude: "37.7695",
longitude: "-122.385",
};
export const LOCALHOST_IP = "63.141.57.109";
```
Sources: [packages/utils/src/constants/localhost.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/localhost.ts#L1-L10)
## Local Infrastructure with Docker Compose
### Overview
The local development stack relies on Docker Compose to provision backing services, including a MySQL database configured for PlanetScale compatibility and a local mail server. Sources: [apps/web/docker-compose.yml:1-46](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml#L1-L46)
### Docker Compose Service Configuration
The `apps/web/docker-compose.yml` configuration specifies version `3.8` and defines three primary services alongside a persistent volume for database storage. Sources: [apps/web/docker-compose.yml:1-46](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml#L1-L46)
| Service Name | Image | Host Ports | Container Ports | Dependencies / Links | Purpose |
| --- | --- | --- | --- | --- | --- |
| `ps-mysql` | `mysql:8.0` | `3306:3306` | `3306` | None (Volume: `ps-mysql`) | MySQL 8.0 server instance configured with native passwords, an empty root password, and a default database named `planetscale`. |
| `planetscale-proxy` | `ghcr.io/mattrobenolt/ps-http-sim:latest` | `3900:3900` | `3900` | `ps-mysql` (depends_on, links) | PlanetScale HTTP simulator proxying requests to the MySQL backend without authentication on port 3900. |
| `mailhog` | `mailhog/mailhog:latest` | `1025:1025`, `8025:8025` | `1025`, `8025` | None | Local email testing service capturing SMTP traffic on port 1025 with a web dashboard exposed on port 8025. |
Sources: [apps/web/docker-compose.yml:5-43](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml#L5-L43)
> [!WARNING]
> The Docker Compose configuration is explicitly intended for local development only and must not be utilized in production environments. Sources: [apps/web/docker-compose.yml:1-2](https://github.com/blade47/dub/blob/HEAD/apps/web/docker-compose.yml#L1-L2)
## Core Database Seeding Workflow
### Overview
The database seeding workflow initializes the primary storage layer, loads structured test fixtures, and establishes a default entity graph containing workspaces, user roles, domains, email domains, folders, rewards, partner groups, programs, and partner accounts. Sources: [apps/web/scripts/dev/seed.ts:1-241](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L1-L241), [apps/web/scripts/dev/data.json:1-181](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L1-L181)
### Seed Data Schema and Structure
The seeding utility reads static fixture payloads defined in `data.json` and maps them into strongly typed Prisma create operations. The root structure consists of distinct entity arrays and singletons. Sources: [apps/web/scripts/dev/seed.ts:121-135](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L121-L135), [apps/web/scripts/dev/data.json:1-181](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L1-L181)
| Seed Field | Target Entity / Type | Key Properties | Purpose |
| --- | --- | --- | --- |
| `workspace` | `Project` | `id`, `name`, `slug`, `plan`, `usageLimit`, `defaultProgramId` | Defines the primary enterprise tenant entity (`Acme, Inc.`) with strict feature limits and configuration flags. Sources: [apps/web/scripts/dev/seed.ts:24-48](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L24-L48), [apps/web/scripts/dev/data.json:2-25](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L2-L25) |
| `users` | `User` & `ProjectUsers` | `id`, `name`, `email`, `emailVerified`, `role` | Provisions internal test accounts with distinct workspace access roles (`owner`, `member`, `viewer`, `billing`). Sources: [apps/web/scripts/dev/seed.ts:106-110](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L106-L110), [apps/web/scripts/dev/data.json:26-55](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L26-L55) |
| `domains` | `Domain` | `id`, `slug`, `verified` | Registers verified routing domains such as `dub.sh` under the workspace. Sources: [apps/web/scripts/dev/seed.ts:50](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L50), [apps/web/scripts/dev/data.json:56-62](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L56-L62) |
| `emailDomains` | `EmailDomain` | `id`, `slug`, `status` | Configures verified email domains such as `getacme.link`. Sources: [apps/web/scripts/dev/seed.ts:52](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L52), [apps/web/scripts/dev/data.json:63-69](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L63-L69) |
| `folders` | `Folder` | `id`, `name`, `description`, `accessLevel` | Sets up default link organization folders with specific access levels. Sources: [apps/web/scripts/dev/seed.ts:54](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L54), [apps/web/scripts/dev/data.json:70-77](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L70-L77) |
| `rewards` | `Reward` | `id`, `groupId`, `event`, `type`, `amountInCents`, `maxDuration` | Establishes payout rules for conversion events such as flat lead and sale bonuses. Sources: [apps/web/scripts/dev/seed.ts:56-68](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L56-L68), [apps/web/scripts/dev/data.json:78-99](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L78-L99) |
| `groups` | `PartnerGroup` | `id`, `name`, `slug`, `leadRewardId`, `saleRewardId`, `defaultLinks` | Categorizes partners into commission tiers with custom domain validation and default link payloads. Sources: [apps/web/scripts/dev/seed.ts:70-81](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L70-L81), [apps/web/scripts/dev/data.json:100-123](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L100-L123) |
| `program` | `Program` | `id`, `name`, `slug`, `defaultFolderId`, `defaultGroupId`, `domain` | Defines the referral program settings linking folders, groups, and domains. Sources: [apps/web/scripts/dev/seed.ts:83-104](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L83-L104), [apps/web/scripts/dev/data.json:124-136](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L124-L136) |
| `partners` | `Partner` | `id`, `name`, `email`, `country`, `user` | Generates affiliated partner profiles tied to individual user accounts. Sources: [apps/web/scripts/dev/seed.ts:111-119](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L111-L119), [apps/web/scripts/dev/data.json:137-181](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L137-L181) |
Sources: [apps/web/scripts/dev/seed.ts:24-135](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L24-L135), [apps/web/scripts/dev/data.json:1-181](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L1-L181)
### Seeding Execution Call Chain
The execution pipeline reads the local fixture file and invokes sequential database creation handlers to construct relational dependencies in proper foreign key order. Sources: [apps/web/scripts/dev/seed.ts:137-241](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L137-L241)
1. `parseJSON()` — Reads and parses `apps/web/scripts/dev/data.json` into memory as a `SeedData` structure. Sources: [apps/web/scripts/dev/seed.ts:137-141](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L137-L141)
2. `createWorkspace()` — Inserts the root tenant record into `prisma.project`. Sources: [apps/web/scripts/dev/seed.ts:144-152](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L144-L152)
3. `createUsers()` — Hashes the default password (`password`), executes `prisma.user.createMany()`, assigns workspace memberships via `prisma.projectUsers.createMany()`, queries back generated relation identifiers, and initializes `prisma.notificationPreference.createMany()`. Sources: [apps/web/scripts/dev/seed.ts:155-210](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L155-L210)
4. `createDomains()` — Populates verified routing domains through `prisma.domain.createMany()`. Sources: [apps/web/scripts/dev/seed.ts:213-231](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L213-L231)
5. `createEmailDomains()` — Commits email domain verification states to the database. Sources: [apps/web/scripts/dev/seed.ts:233-241](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L233-L241)
> [!WARNING]
> Because `createMany` batch operations do not return auto-incremented database identifiers across all database adapters, the user seeding procedure explicitly queries back created `projectUsers` records to map user identifiers before creating dependent notification preferences. Sources: [apps/web/scripts/dev/seed.ts:187-198](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L187-L198)
> [!NOTE]
> All seeded user accounts share a uniform preset password hash derived from the plaintext string `"password"` via `hashPassword("password")`. Sources: [apps/web/scripts/dev/seed.ts:163](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts#L163)
## Partner and Referral Mock Seeding
### Overview
The local development environment provides specialized scripts to seed partner-specific data structures, including test affiliate accounts, referral program applications, and financial commission records. These scripts execute against the active Prisma client instance configured via `dotenv-flow/config`. Sources: [apps/web/scripts/dev/seed-application-events.ts:1-3](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-application-events.ts#L1-L3), [apps/web/scripts/dev/seed-partner-enrollment.ts:1-4](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L1-L4), [apps/web/scripts/dev/seed-commissions.ts:1-4](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-commissions.ts#L1-L4)
### Partner Enrollment and User Generation
The `seed-partner-enrollment.ts` script constructs a complete partner profile graph anchored to a designated program ID (`prog_1K2J9DRWPPJ2F1RX53N92TSGA`) and referring partner ID (`pn_1K2J9DRWPPJ2F1RX53N92TSGG`). Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:6-8](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L6-L8)
1. `prisma.program.findUnique()` — Queries the target program to retrieve its `defaultGroupId`. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:10-17](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L10-L17)
2. `nanoid(3)` & `createId()` — Generates a unique 3-character suffix and prefixed identifiers (`user_`, `pn_`, `pge_`) for the new account. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:24-29](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L24-L29)
3. `prisma.user.create()` — Registers the user with an email following the format `partner-{suffix}@dub-internal-test.com` and links their `defaultPartnerId`. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:31-39](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L31-L39)
4. `prisma.partner.create()` — Establishes the core partner profile entity. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:41-47](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L41-L47)
5. `prisma.partnerUser.create()` — Binds the user to the partner organization with an `owner` role and initializes notification preferences. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:49-58](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L49-L58)
6. `prisma.programEnrollment.create()` — Enrolls the partner into the target program with a `pending` status. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:60-68](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L60-L68)
7. `prisma.programApplicationEvent.create()` — Logs an immediate application event with `direct` referral source and current timestamps. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:70-83](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L70-L83)
> [!NOTE]
> The enrollment script targets the hardcoded program identifier `prog_1K2J9DRWPPJ2F1RX53N92TSGA` and terminates execution immediately with an error log if the program record is absent from the database. Sources: [apps/web/scripts/dev/seed-partner-enrollment.ts:6](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L6), [apps/web/scripts/dev/seed-partner-enrollment.ts:19-22](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-partner-enrollment.ts#L19-L22)
### Referral Application Event Seeding
The `seed-application-events.ts` script populates historical `ProgramApplicationEvent` records for ACME program participants using randomized temporal offsets and distribution pools. Sources: [apps/web/scripts/dev/seed-application-events.ts:27-97](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-application-events.ts#L27-L97)
| Referral Source Constant | Domain Value |
| :--- | :--- |
| `direct` | Direct traffic or bookmark |
| `linkedin.com` | Professional network referral |
| `twitter.com` | Social media link |
| `marketplace` | Internal marketplace discovery |
| `acme.com` | Corporate domain referral |
| `google.com` | Search engine referral |
Sources: [apps/web/scripts/dev/seed-application-events.ts:18-25](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-application-events.ts#L18-L25)
The script queries program enrollments excluding the reserved partner ID `pn_1K2J9DRWPPJ2F1RX53N92TSGH`, calculates randomized timestamps spanning a 30-day window, determines approval status via a 70% probability threshold (`Math.random() < 0.7`), and commits the batch via `prisma.programApplicationEvent.createMany()`. Sources: [apps/web/scripts/dev/seed-application-events.ts:31-92](https://github.com/blade47/dub/scripts/dev/seed-application-events.ts#L31-L92)
### Commission Record Seeding
The `seed-commissions.ts` script inserts mock financial earnings records for testing payout dashboards and ledger reconciliations. Sources: [apps/web/scripts/dev/seed-commissions.ts:6-41](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-commissions.ts#L6-L41)
```typescript
const commissions: Prisma.CommissionCreateManyInput[] = [
{
id: createId({ prefix: "cm_" }),
programId,
partnerId,
type: "referral",
amount: 0,
quantity: 1,
earnings: 10000,
createdAt: new Date(),
},
{
id: createId({ prefix: "cm_" }),
programId,
partnerId,
type: "referral",
amount: 0,
quantity: 1,
earnings: 20000,
createdAt: new Date(),
},
];
await prisma.commission.createMany({
data: commissions,
skipDuplicates: true,
});
```
Sources: [apps/web/scripts/dev/seed-commissions.ts:10-36](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-commissions.ts#L10-L36)
## Third-Party Integration and External Fixtures
### Overview
Local development of external provider connections requires provisioning integration metadata and installing fixture credentials into the database via dedicated setup scripts. Environment loading is initialized through `dotenv-flow/config` across script entry points. Sources: [apps/web/scripts/dev/seed-integration.ts:5](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-integration.ts#L5), [apps/web/scripts/create-integration.ts:4](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L4)
### Integration Metadata Creation
The `create-integration.ts` script registers third-party definitions in the database by performing an `upsert` operation on the `prisma.integration` model using `GOOGLE_ADS_INTEGRATION_ID`. Sources: [apps/web/scripts/create-integration.ts:7-9](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L7-L9)
| Field | Value | Purpose |
| :--- | :--- | :--- |
| `id` | `GOOGLE_ADS_INTEGRATION_ID` | Primary integration identifier constant |
| `name` | `"Google Ads"` | Human-readable display name |
| `slug` | `"google-ads"` | URL-safe routing slug |
| `description` | `"Upload offline click conversions to Google Ads to optimize ad performance."` | Provider overview text |
| `developer` | `"Dub"` | Entity maintaining the integration |
| `website` | `"https://ads.google.com"` | Official external provider URL |
| `verified` | `true` | Official verification status flag |
| `projectId` | `DUB_WORKSPACE_ID` | Owning workspace context |
| `category` | `"Analytics"` | Functional taxonomy grouping |
Sources: [apps/web/scripts/create-integration.ts:11-22](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L11-L22)
> [!NOTE]
> The `upsert` strategy ensures safe re-execution during local environment resets by updating existing record fields (`name`, `slug`, `description`, `verified`, `category`) if the integration ID already exists. Sources: [apps/web/scripts/create-integration.ts:7-30](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L7-L30)
### Installed Integration Fixtures and Secrets
The `seed-integration.ts` script provisions workspace-level installed integration instances using `prisma.installedIntegration.upsert()` with a composite unique key constraint (`userId_integrationId_projectId`). Sources: [apps/web/scripts/dev/seed-integration.ts:8-15](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-integration.ts#L8-L15)
```typescript
await prisma.installedIntegration.upsert({
where: {
userId_integrationId_projectId: {
userId: "cl7p1s07k000687rbuhpwqkqa",
integrationId: INTERCOM_INTEGRATION_ID,
projectId: ACME_WORKSPACE_ID,
},
},
create: {
userId: "cl7p1s07k000687rbuhpwqkqa",
integrationId: INTERCOM_INTEGRATION_ID,
projectId: ACME_WORKSPACE_ID,
credentials: {
appId: "xxx",
accessToken: encrypt("xxx"),
},
},
update: {
//
},
});
```
Sources: [apps/web/scripts/dev/seed-integration.ts:8-28](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-integration.ts#L8-L28)
> [!WARNING]
> Sensitive integration secrets like access tokens must be passed through the application encryption helper function (`encrypt("xxx")`) before being committed to the `credentials JSONB` payload column. Sources: [apps/web/scripts/dev/seed-integration.ts:22](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-integration.ts#L22)
### Stripe App Configuration Extension
Stripe application local development configuration is structured via extension inheritance in `packages/stripe-app/stripe-app.dev.json`, which inherits properties directly from the base `stripe-app.json` manifest. Sources: [packages/stripe-app/stripe-app.dev.json:1-3](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.dev.json#L1-L3)
## Search Benchmarks and Large-Scale Seeding
### Overview
Populating large-volume partner data, generating vector embeddings, and running latency benchmarks require specialized initialization scripts. These tools manage atomic chunked database insertions, backfill search indices across partitioned program loops, and evaluate retrieval performance. Sources: [apps/web/scripts/dev/seed-100k-partners.ts:1-7](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts#L1-L7), [apps/web/scripts/dev/upsert-emoji-embeddings.ts:1-84](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/upsert-emoji-embeddings.ts#L1-L84), [apps/web/scripts/partners/backfill-partner-search.ts:1-25](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/partners/backfill-partner-search.ts#L1-L25), [apps/web/scripts/dev/benchmark-partner-search.ts:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L1-L26)
### Large-Scale Partner Seeding and Search Indexing
The `seed-100k-partners.ts` script writes `User`, `Partner`, `PartnerUser`, `ProgramEnrollment`, `PartnerPlatform`, `Link`, and `ProgramPartnerTag` rows in atomic chunks defined by `CHUNK_SIZE` (2,500 partners per chunk). To prevent accidental data corruption or production exposure, safety checks enforce strict environment rules. Sources: [apps/web/scripts/dev/seed-100k-partners.ts:1-49](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts#L1-L49)
| Parameter / Constant | Value / Default | Purpose |
| :--- | :--- | :--- |
| `DEFAULT_COUNT` | `100_000` | Default number of partner records to generate |
| `MAX_COUNT` | `1_000_000` | Upper limit restriction for bulk generation |
| `DEFAULT_SEED` | `"partners-search"` | Base seed string for deterministic generation |
| `CHUNK_SIZE` | `2_500` | Atomic transaction block size for batch database inserts |
| `LOCAL_DATABASE_HOSTS` | `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, `host.docker.internal`, `mysql`, `db` | Permitted hosts for local database connection safety checks |
Sources: [apps/web/scripts/dev/seed-100k-partners.ts:37-49](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts#L37-L49)
> [!CAUTION]
> The seeding script inserts login-capable users with a known password. It refuses non-local `DATABASE_URL` connections unless `--allowRemoteDatabase` is explicitly passed, and refuses production environments (`NODE_ENV` or `VERCEL_ENV`) outright. Sources: [apps/web/scripts/dev/seed-100k-partners.ts:18-20](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-100k-partners.ts#L18-L20)
Once seeded, partner enrollments must be indexed into the search provider using `backfill-partner-search.ts`. This utility pages through programs in ID order via `iterateProgramIds()` and pages through enrollments by ID rather than offset to keep per-batch costs flat. Sources: [apps/web/scripts/partners/backfill-partner-search.ts:1-70](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/partners/backfill-partner-search.ts#L1-L70)
```typescript
async function* iterateProgramIds(afterProgram?: string) {
let cursor = afterProgram;
let inclusive = Boolean(afterProgram);
while (true) {
const programs = await prisma.program.findMany({
where: cursor ? { id: inclusive ? { gte: cursor } : { gt: cursor } } : {},
select: { id: true },
orderBy: { id: "asc" },
take: PROGRAM_PAGE_SIZE,
});
if (programs.length === 0) {
return;
}
for (const { id } of programs) {
yield id;
}
cursor = programs[programs.length - 1].id;
inclusive = false;
if (programs.length < PROGRAM_PAGE_SIZE) {
return;
}
}
}
```
Sources: [apps/web/scripts/partners/backfill-partner-search.ts:43-70](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/partners/backfill-partner-search.ts#L43-L70)
### Vector Embedding Upserts
The `upsert-emoji-embeddings.ts` script fetches emoji datasets from `EMOJIBASE_DATA_URL`, processes and deduplicates records by hexcode, and upserts them into an Upstash vector index in batches of `UPSERT_BATCH_SIZE` (500 records). Sources: [apps/web/scripts/dev/upsert-emoji-embeddings.ts:5-84](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/upsert-emoji-embeddings.ts#L5-L84)
```typescript
const EMOJIBASE_DATA_URL =
"https://cdn.jsdelivr.net/npm/emojibase-data@16.0.3/en/data.json";
const UPSERT_BATCH_SIZE = 500;
type EmojiVectorRecord = {
id: string;
data: string;
metadata: {
emoji: string;
label: string;
};
};
```
Sources: [apps/web/scripts/dev/upsert-emoji-embeddings.ts:5-16](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/upsert-emoji-embeddings.ts#L5-L16)
> [!NOTE]
> Execution requires `UPSTASH_VECTOR_EMOJI_REST_URL` and `UPSTASH_VECTOR_EMOJI_REST_TOKEN` environment variables to be configured, failing immediately if either is missing. Sources: [apps/web/scripts/dev/upsert-emoji-embeddings.ts:67-74](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/upsert-emoji-embeddings.ts#L67-L74)
### Search Latency Benchmarking
The `benchmark-partner-search.ts` script measures p99 latency for relevance-ranked partner lists. It calls `getPartners` in process to bypass HTTP routing, authentication, and serialization overhead, establishing a baseline floor for API performance. Sources: [apps/web/scripts/dev/benchmark-partner-search.ts:1-9](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L1-L9)
| Benchmark Configuration Flag | Default Value | Limit / Constraint | Purpose |
| :--- | :--- | :--- | :--- |
| `--requests` | `1,000` | Minimum: `1,000` | Total request count for latency percentile calculation |
| `--warmup` | `50` | Non-negative integer | Initial unmeasured warmup request iterations |
| `--concurrency` | `10` | Cannot exceed `--requests` | Parallel execution concurrency bound |
| `--pageSize` | `25` | Maximum: `PARTNER_SEARCH_CANDIDATE_LIMIT` | Result pagination size per query |
| `--thresholdMs` | `1,000` | Positive integer | Maximum acceptable p99 latency threshold |
| `--sampleSize` | `100` | Max: `1,000` (`10,000` with `--searchOnly`) | Partner sampling count across the program index |
| `--maxErrorRate` | `0` | 0 to 100 percentage | Maximum allowable request error rate percentage |
Sources: [apps/web/scripts/dev/benchmark-partner-search.ts:44-56](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L44-L56), [apps/web/scripts/dev/benchmark-partner-search.ts:97-190](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L97-L190)
> [!TIP]
> Passing `--searchOnly` times the search provider's candidate query alone while skipping the local database. This permits a higher sample size ceiling (`MAX_SEARCH_ONLY_SAMPLE_SIZE` of 10,000) and enables direct provider comparison without local database bottlenecks. Sources: [apps/web/scripts/dev/benchmark-partner-search.ts:10-12](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L10-L12), [apps/web/scripts/dev/benchmark-partner-search.ts:54](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L54), [apps/web/scripts/dev/benchmark-partner-search.ts:169-178](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/benchmark-partner-search.ts#L169-L178)
## Related
- [[Overview]]
- [[Project Structure]]
- [[Database Seeding and Testing]]
---
## Technical docs: Commission Export Batching
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/how-it-works/commission-export-batching
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts)
- [apps/web/app/ee/api/cron/export/commissions/fetch-commissions-batch.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts)
- [apps/web/lib/api/commissions/get-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts)
- [apps/web/lib/api/commissions/metadata-filters.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts)
- [apps/web/lib/api/errors.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/errors.ts)
## Overview
The execution flow tracing from `POST` down to `DubApiError` governs the processing of large background commission exports triggered via QStash. When an export cron request hits the API route, it verifies signatures, parses input payloads, retrieves batches of commissions with optional metadata filters, and generates downloadable CSV reports. If validation failures or invalid query cursors occur during this pipeline, errors are caught, logged, and structured into standard API error responses using `DubApiError`.
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts:22-114](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L22-L114)
---
### Step 1: POST
The execution begins at the `POST` route handler for commission export cron jobs. It reads the incoming request body, validates the QStash signature, and parses the payload using a Zod schema to extract filters, `programId`, `columns`, and `userId`. It verifies the existence of the target user and program in the database before initiating batch processing.
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts:23-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app/(ee)/api/cron/export/commissions/route.ts#L23-L67)
---
### Step 2: fetchCommissionsBatch
To handle large export datasets without memory exhaustion, the router invokes the `fetchCommissionsBatch` async generator. This function iterates through paginated database queries by requesting fixed-size batches (defaulting to 1,000 records per page) until all matching records have been retrieved.
Sources: [apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts:12-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts#L12-L34)
---
### Step 3: getCommissions
Inside the batch generator, `getCommissions` executes the underlying database queries via Prisma. It processes filtering parameters such as partner IDs, statuses, date ranges, and pagination cursors. It also validates pagination cursor IDs to guarantee that provided cursors belong to the correct program.
Sources: [apps/web/lib/api/commissions/get-commissions.ts:35-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts#L35-L97)
---
### Step 4: parseCommissionMetadataQuery
When commission queries include metadata filtering expressions, `parseCommissionMetadataQuery` validates and parses the raw query string. It normalizes quotes, verifies that logical operators (AND/OR) are not improperly mixed, checks condition limits, and splits the expression into distinct filter segments.
Sources: [apps/web/lib/api/commissions/metadata-filters.ts:109-170](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts#L109-L170)
---
### Step 5: parseCondition
Each individual segment of the metadata query is passed to `parseCondition`. This function uses regular expressions to isolate the metadata key, operator, and raw value, ensuring keys adhere to valid identifier rules and that empty or malformed conditions are rejected.
Sources: [apps/web/lib/api/commissions/metadata-filters.ts:34-83](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts#L34-L83)
---
### Step 6: mapOperator
The `mapOperator` helper translates raw operator tokens (such as `=`, `:`, or `!=`) into internal `CommissionMetadataFilterOp` representations (`equals` or `notEquals`). If an unsupported operator is supplied, it throws a structured API error.
Sources: [apps/web/lib/api/commissions/metadata-filters.ts:19-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts#L19-L32)
---
### Step 7: DubApiError
When validation failures occur—such as invalid metadata operators, keys containing forbidden characters, or invalid pagination cursors—the application throws a `DubApiError` instance. The top-level `POST` catch block captures this error, logs it via Axiom, and converts it into a standardized JSON error response with appropriate HTTP status codes.
Sources: [apps/web/lib/api/errors.ts:44-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/errors.ts#L44-L61), [apps/web/lib/api/errors.ts:106-131](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/errors.ts#L106-L131)
---
## Sequence Diagram
```mermaid
sequenceDiagram
participant Route as POST (route.ts)
participant Batch as fetchCommissionsBatch
participant GetComm as getCommissions
participant MetaQuery as parseCommissionMetadataQuery
participant Cond as parseCondition
participant Op as mapOperator
participant Err as DubApiError
Route->>Batch: fetchCommissionsBatch(filters)
loop Paginated Batches
Batch->>GetComm: getCommissions(filters + page)
GetComm->>MetaQuery: parseCommissionMetadataQuery(query)
alt Invalid Query Structure
MetaQuery->>Err: throw DubApiError
end
MetaQuery->>Cond: parseCondition(trimmedCondition)
alt Invalid Metadata Key/Value
Cond->>Err: throw DubApiError
end
Cond->>Op: mapOperator(operator)
alt Unsupported Operator
Op->>Err: throw DubApiError
end
Op-->>Cond: return filter op
Cond-->>MetaQuery: return parsed filter
MetaQuery-->>GetComm: return parsed metadata where clause
GetComm-->>Batch: return commissions array
end
Batch-->>Route: yield commissions batch
```
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts:75-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L75-L79), [apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts:19-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts#L19-L33), [apps/web/lib/api/commissions/get-commissions.ts:58-96](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts#L58-L96), [apps/web/lib/api/commissions/metadata-filters.ts:19-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts#L19-L168), [apps/web/lib/api/errors.ts:44-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/errors.ts#L44-L61)
---
## Flowchart
```mermaid
flowchart TD
A[POST Request] --> B[fetchCommissionsBatch]
B --> C[getCommissions]
C --> D[parseCommissionMetadataQuery]
D --> E{Valid Query?}
E -- No --> Z[DubApiError]
E -- Yes --> F[parseCondition]
F --> G{Valid Condition?}
G -- No --> Z
G -- Yes --> H[mapOperator]
H --> I{Supported Op?}
I -- No --> Z
I -- Yes --> J[Execute Prisma Query]
J --> K[Return Commissions Batch]
```
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts:23-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app/(ee)/api/cron/export/commissions/route.ts#L23-L79), [apps/web/lib/api/commissions/get-commissions.ts:58-208](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts#L58-L208), [apps/web/lib/api/commissions/metadata-filters.ts:19-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/metadata-filters.ts#L19-L168), [apps/web/lib/api/errors.ts:44-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/errors.ts#L44-L61)
---
## Key Observations
- **Modular Boundaries:** The execution flow seamlessly crosses API route handlers, async generator utilities, query builders, metadata parsers, and centralized error management layers.
- **Robust Validation:** Metadata queries undergo multi-stage validation checking condition limits, mixing of logical operators, and structural syntax rules before ever reaching the database layer.
- **Error Handling:** Any validation or execution failure thrown as a `DubApiError` is caught at the root API handler, logged to Axiom, and returned with precise HTTP status mapping.
---
## Technical docs: Project Structure
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/getting-started/project-structure
Relevant source files
The following files were used as context for generating this wiki page:
- [package.json](https://github.com/blade47/dub/blob/HEAD/package.json)
- [pnpm-workspace.yaml](https://github.com/blade47/dub/blob/HEAD/pnpm-workspace.yaml)
- [apps/web/package.json](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json)
- [turbo.json](https://github.com/blade47/dub/blob/HEAD/turbo.json)
- [packages/utils/tsup.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/tsup.config.ts)
- [apps/web/app/api/old/projects/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/route.ts)
- [packages/ui/tsup.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/tsup.config.ts)
- [packages/ui/package.json](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json)
- [apps/web/app/api/old/projects/slug/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/route.ts)
- [packages/hubspot-app/package.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/package.json)
- [apps/web/app/domain/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx)
- [packages/email/package.json](https://github.com/blade47/dub/blob/HEAD/packages/email/package.json)
- [packages/cli/tsconfig.json](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsconfig.json)
- [packages/utils/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/index.ts)
- [apps/web/app/ee/app.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/layout.tsx)
- [apps/web/tsconfig.json](https://github.com/blade47/dub/blob/HEAD/apps/web/tsconfig.json)
- [packages/embeds/core/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/index.ts)
- [packages/stripe-app/stripe-app.dev.json](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.dev.json)
- [packages/embeds/core/package.json](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/package.json)
- [apps/web/app/api/old/projects/slug/domains/default/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/default/route.ts)
- [apps/web/app/api/old/projects/slug/domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/route.ts)
- [packages/tsconfig/package.json](https://github.com/blade47/dub/blob/HEAD/packages/tsconfig/package.json)
- [apps/web/lib/auth/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/index.ts)
- [apps/web/app/app.dub.co/marketplace/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx)
- [apps/web/ui/program-marketplace/external/marketplace-external-router.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx)
- [apps/web/app/api/old/projects/slug/links/info/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/links/info/route.ts)
- [packages/embeds/core/tsup.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/tsup.config.ts)
- [apps/web/app/api/old/projects/slug/domains/domain/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/%5Bdomain%5D/route.ts)
- [packages/cli/tsup.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsup.config.ts)
- [packages/utils/package.json](https://github.com/blade47/dub/blob/HEAD/packages/utils/package.json)
## Overview
The Dub project is structured as a scalable pnpm and Turborepo-orchestrated monorepo combining a core Next.js web application with a robust collection of shared internal libraries, compiler configurations, and specialized external micro-apps. By organizing source code into modular workspace packages under dedicated directories, the architecture streamlines multi-tenancy, build pipelines, and publishing workflows across the entire ecosystem.
Sources: [package.json:1-35](https://github.com/blade47/dub/blob/HEAD/package.json#L1-L35), [pnpm-workspace.yaml:1-6](https://github.com/blade47/dub/blob/HEAD/pnpm-workspace.yaml#L1-L6), [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
This layout addresses the complexity of maintaining shared utility infrastructure, robust UI component standards, enterprise routing domains, and client-side embeddable SDKs within a unified codebase. Centralized compiler options and bundler configurations ensure consistent packaging and seamless cross-workspace dependencies.
Sources: [packages/ui/package.json:1-133](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json#L1-L133), [packages/utils/package.json:1-62](https://github.com/blade47/dub/blob/HEAD/packages/utils/package.json#L1-L62), [packages/tsconfig/package.json:1-9](https://github.com/blade47/dub/blob/HEAD/packages/tsconfig/package.json#L1-L9)
## Monorepo Workspace Orchestration
### Monorepo Workspace Orchestration
### Overview
The monorepo architecture is governed at the root by `pnpm-workspace.yaml`, which defines the package discovery topology across application and library directories. The workspace spans four inclusion patterns: `apps/*`, `apps/web/.react-email`, `packages/*`, and `packages/embeds/*`. Package dependency resolution uses pnpm version `9.15.9` as specified by the `packageManager` field in the root `package.json`.
Sources: [pnpm-workspace.yaml:1-6](https://github.com/blade47/dub/blob/HEAD/pnpm-workspace.yaml#L1-L6), [package.json:34-34](https://github.com/blade47/dub/blob/HEAD/package.json#L34-L34)
### Root Workspace Configuration
The root `package.json` establishes the monorepo as a private project under the `AGPL-3.0-or-later` license. It defines global devDependencies including `@dub/tailwind-config` bound via `workspace:*`, `eslint` (`^8.48.0`), `prettier` (`^3.2.5`), `prettier-plugin-organize-imports` (`^3.2.4`), `prettier-plugin-tailwindcss` (`^0.6.0`), `tsconfig` bound via `workspace:*`, and `turbo` (`^1.12.5`). A resolutions block pins `chrono-node` to version `2.7.5`.
Sources: [package.json:1-35](https://github.com/blade47/dub/blob/HEAD/package.json#L1-L35)
> [!NOTE]
> Root devDependencies such as `tsconfig` and `@dub/tailwind-config` use the `workspace:*` protocol to ensure internal packages resolve directly to their local workspace sources rather than external registries.
Sources: [package.json:22-30](https://github.com/blade47/dub/blob/HEAD/package.json#L22-L30)
### Root Scripts and Publishing Workflows
The root scripts orchestrate Turborepo pipelines for building, developing, linting, cleaning, and testing, alongside targeted package publishing commands that filter execution by specific workspace identifiers.
| Script Name | Command | Purpose |
| :--- | :--- | :--- |
| `build` | `turbo build` | Executes the Turborepo build pipeline across the monorepo. |
| `build:packages` | `pnpm -r --filter "./packages/**" build` | Recursively builds all packages within the `./packages/` glob. |
| `dev` | `turbo dev` | Starts development servers via Turborepo with persistence enabled. |
| `lint` | `turbo lint` | Runs linting tasks across workspace packages. |
| `clean` | `turbo clean` | Cleans build artifacts and caches via Turborepo. |
| `format` | `prettier --write "**/*.{ts,tsx,md}"` | Formats all TypeScript, TSX, and Markdown files with Prettier. |
| `prettier-check` | `prettier --check "**/*.{ts,tsx,md}"` | Verifies code formatting across the repository. |
| `publish-cli` | `turbo build --filter='@dub/cli' && cd packages/cli && npm publish && cd ../../` | Builds and publishes the `@dub/cli` package. |
| `publish-embed-core` | `turbo build --filter='@dub/embed-core' && cd packages/embeds/core && npm publish && cd ../../../` | Builds and publishes the `@dub/embed-core` package. |
| `publish-embed-react` | `turbo build --filter='@dub/embed-react' && cd packages/embeds/react && npm publish && cd ../../../` | Builds and publishes the `@dub/embed-react` package. |
| `publish-tw` | `turbo build --filter='@dub/tailwind-config' && cd packages/tailwind-config && npm publish && cd ../../` | Builds and publishes `@dub/tailwind-config`. |
| `publish-ui` | `turbo build --filter='@dub/ui' && cd packages/ui && npm publish && cd ../../` | Builds and publishes the `@dub/ui` component library. |
| `publish-utils` | `turbo build --filter='@dub/utils' && cd packages/utils && npm publish && cd ../../` | Builds and publishes the `@dub/utils` helper package. |
| `script` | `echo 'Run this script in apps/web'` | Placeholder script directing developers to the web application app directory. |
| `test` | `turbo run test` | Executes test suites across the monorepo via Turborepo. |
Sources: [package.json:5-21](https://github.com/blade47/dub/blob/HEAD/package.json#L5-L21)
### Turborepo Pipeline Orchestration
Task execution is structured by `turbo.json` under schema `https://turbo.build/schema.json`. The pipeline defines global dependencies on any `.env` file (`**/.env`) across all tasks. Four primary pipeline targets dictate execution dependencies, caching rules, and output artifacts:
| Pipeline Task | `dependsOn` | `cache` | `persistent` | `outputs` |
| :--- | :--- | :--- | :--- | :--- |
| `build` | `^build` | Enabled (default) | Not set | `["!.next/cache/**", ".next/**", "dist/**"]` |
| `dev` | Not set | `false` | `true` | Not set |
| `clean` | Not set | `false` | Not set | Not set |
| `test` | `^build` | Enabled (default) | Not set | Not set |
Sources: [turbo.json:1-20](https://github.com/blade47/dub/blob/HEAD/turbo.json#L1-L20)
> [!WARNING]
> The `dev` and `clean` pipeline tasks explicitly disable Turborepo caching (`"cache": false`). Additionally, `dev` is marked as persistent (`"persistent": true`) to support long-running development watcher processes.
Sources: [turbo.json:9-15](https://github.com/blade47/dub/blob/HEAD/turbo.json#L9-L15)
## Shared Tooling and Compiler Configs
### Overview
The monorepo relies on standardized bundler configurations and TypeScript compiler options across its internal packages and applications. Bundling is handled primarily through `tsup`, leveraging esbuild for high-speed compilation, generation of declaration files (`dts`), code minification, and conditional workspace clean routines. TypeScript environments are similarly governed by base configuration packages and package-level `tsconfig.json` files that establish strict type-checking, path aliasing, and module resolution rules.
Sources: [packages/utils/tsup.config.ts:1-11](https://github.com/blade47/dub/blob/HEAD/packages/utils/tsup.config.ts#L1-L11), [packages/ui/tsup.config.ts:1-20](https://github.com/blade47/dub/blob/HEAD/packages/ui/tsup.config.ts#L1-L20)
### Tsup Bundler Standards and Options
Packages across the workspace customize `tsup` to emit specific output formats (`esm` or `cjs`), define entry points, handle banner injections, and declare external dependencies such as React.
| Package | Entry Points | Format | Minify | DTS | Clean Strategy | External / Banner |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| `packages/utils` | `["src/**/*.ts"]` | `["esm"]` | `true` | `true` | `process.env.VERCEL === "1"` | External: `["react"]` |
| `packages/ui` | `index: "src/index.tsx"`, `"icons/index": "src/icons/index.tsx"`, `"charts/index": "src/charts/index.ts"` | `["esm"]` | `true` | `true` | `process.env.VERCEL === "1"` | External: `["react"]`; Banner JS: `'"use client"'` |
| `packages/embeds/core` | `"embed/script": "src/embed.ts"`, `index: "src/index.ts"` | `["cjs"]` | `true` | `true` | `true` (unconditional) | Splitting: `false`; Banner JS: `'"use client"'` |
| `packages/cli` | `["src/index.ts"]` | `["esm"]` | `true` | `true` | `true` (unconditional) | Target: `"esnext"`, Sourcemap: `true`, OutDir: `"dist"` |
Sources: [packages/utils/tsup.config.ts:1-11](https://github.com/blade47/dub/blob/HEAD/packages/utils/tsup.config.ts#L1-L11), [packages/ui/tsup.config.ts:1-20](https://github.com/blade47/dub/blob/HEAD/packages/ui/tsup.config.ts#L1-L20), [packages/embeds/core/tsup.config.ts:1-18](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/tsup.config.ts#L1-L18), [packages/cli/tsup.config.ts:1-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsup.config.ts#L1-L12)
> [!TIP]
> Both `@dub/ui` and `@dub/embed-core` inject a `"use client"` banner via esbuild options during bundling to ensure consumer frameworks correctly treat their components as client-side modules.
Sources: [packages/ui/tsup.config.ts:10-14](https://github.com/blade47/dub/blob/HEAD/packages/ui/tsup.config.ts#L10-L14), [packages/embeds/core/tsup.config.ts:9-13](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/tsup.config.ts#L9-L13)
### TypeScript Compiler Configuration
TypeScript settings enforce strict type safety and modular workspace referencing. For instance, `packages/cli/tsconfig.json` specifies strict mode, Node module resolution, and path aliasing mapping `@/*` to `./src/*`.
```json
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Default",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"moduleResolution": "node",
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
```
Sources: [packages/cli/tsconfig.json:1-26](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsconfig.json#L1-L26)
Application-level configurations, such as `apps/web/tsconfig.json`, extend shared base configs like `tsconfig/nextjs.json` and establish comprehensive path aliases for pages, scripts, styles, ui, and libraries alongside explicit inclusions for monorepo packages like `packages/blocks/src/event-list.tsx` and `packages/ui/src/hooks/use-pagination.ts`.
```json
{
"extends": "tsconfig/nextjs.json",
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/pages/*": ["pages/*"],
"@/scripts/*": ["scripts/*"],
"@/styles/*": ["styles/*"],
"@/ui/*": ["ui/*"],
"@/lib/*": ["lib/*"]
},
"downlevelIteration": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"strict": false,
"strictNullChecks": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"../../packages/blocks/src/event-list.tsx",
"../../packages/ui/src/hooks/use-pagination.ts"
],
"exclude": ["node_modules", "playwright"]
}
```
Sources: [apps/web/tsconfig.json:1-43](https://github.com/blade47/dub/blob/HEAD/apps/web/tsconfig.json#L1-L43)
> [!WARNING]
> Environment-conditional cleaning (`clean: process.env.VERCEL === "1"`) is used in library packages like `packages/utils` and `packages/ui` to optimize Vercel build performance, whereas standalone tools and embeds enforce unconditional directory cleaning (`clean: true`).
Sources: [packages/utils/tsup.config.ts:8-8](https://github.com/blade47/dub/blob/HEAD/packages/utils/tsup.config.ts#L8-L8), [packages/ui/tsup.config.ts:17-17](https://github.com/blade47/dub/blob/HEAD/packages/ui/tsup.config.ts#L17-L17), [packages/embeds/core/tsup.config.ts:16-16](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/tsup.config.ts#L16-L16), [packages/cli/tsup.config.ts:4-4](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsup.config.ts#L4-L4)
## Core Application Architecture
### Overview
The `apps/web` Next.js application manages its routing layout structure through specialized route groups, dynamic multi-tenant domain segments, and isolated enterprise configurations. Script tasks within `apps/web/package.json` coordinate generation workflows like Prisma client generation (`prisma:generate`), concurrent development servers running Next.js with Turbopack on port `8888`, openapi generation (`generate-openapi`), and test runner suites via Vitest and Playwright.
Sources: [apps/web/package.json:5-19](https://github.com/blade47/dub/blob/HEAD/apps/web/package.json#L5-L19)
### Routing Structure and Enterprise Layouts
Multi-tenant domain routing and marketplace sub-layouts govern how page content is rendered across distinct contexts. The dynamic domain layout (`apps/web/app/[domain]/layout.tsx`) wraps child components inside a neutral background container bounded by a mobile navigation bar (`NavMobile`), standard navigation (`Nav`), and footer components (`Footer`).
```tsx
import { Footer, Nav, NavMobile } from "@dub/ui";
export default function ExternalPagesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
```
Sources: [apps/web/app/domain/layout.tsx:1-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx#L1-L16)
Enterprise app routing maps group entry points directly, such as `apps/web/app/(ee)/app.dub.co/layout.tsx` re-exporting the layout default directly from `../../app.dub.co/layout`.
Sources: [apps/web/app/ee/app.dub.co/layout.tsx:1-2](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/layout.tsx#L1-L2)
### Marketplace Sub-routing and Segment Resolution
The marketplace section under `apps/web/app/app.dub.co/marketplace/layout.tsx` integrates decorative external grid lines via `MarketplaceExternalGridLines` alongside the external marketplace header and footer.
```tsx
import { MarketplaceExternalHeader } from "@/ui/program-marketplace/external/marketplace-external-header";
import { Footer } from "@dub/ui";
import { PropsWithChildren } from "react";
export default function MarketplaceExternalLayout({
children,
}: PropsWithChildren) {
return (
{children}
);
}
function MarketplaceExternalGridLines() {
return (
);
}
```
Sources: [apps/web/app/app.dub.co/marketplace/layout.tsx:1-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx#L1-L33)
Dynamic segment matching inside the marketplace is handled explicitly by `MarketplaceExternalRouter`. The routing flow evaluates path segment lengths and parameter values to select appropriate page views.
```tsx
import { notFound } from "next/navigation";
import { slugToCategory } from "../utils/urls";
import { MarketplaceExternalHomePage } from "./marketplace-external-home-page";
import { MarketplaceExternalListPage } from "./marketplace-external-list-page";
import { MarketplaceExternalProgramPage } from "./marketplace-external-program-page";
export async function MarketplaceExternalRouter({
segments,
}: {
segments: string[];
}) {
if (segments.length === 0) {
return ;
}
if (segments.length === 1 && segments[0] === "all") {
return ;
}
if (segments.length === 2 && segments[0] === "c") {
const category = slugToCategory(segments[1]);
if (category) {
return (
);
}
}
if (segments.length === 1) {
return ;
}
notFound();
}
```
Sources: [apps/web/ui/program-marketplace/external/marketplace-external-router.tsx:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx#L1-L39)
> [!TIP]
> The `MarketplaceExternalRouter` call chain evaluates `segments.length === 0` to render the home page before checking explicit filters like `all` lists, category route prefixes (`c`), or falling back to single-segment program detail pages or triggering `notFound()`.
Sources: [apps/web/ui/program-marketplace/external/marketplace-external-router.tsx:12-37](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/external/marketplace-external-router.tsx#L12-L37)
## Web Application API and Authentication
### Internal Authentication Modules
The authentication system index module aggregates core administrative, token-hashing, configuration, session, utility, and workspace-level modules under `apps/web/lib/auth/index.ts`. Specifically, it re-exports modules from `./admin`, `./hash-token`, `./options`, `./session`, `./utils`, and `./workspace`.
```ts
export * from "./admin";
export * from "./hash-token";
export * from "./options";
export * from "./session";
export * from "./utils";
export * from "./workspace";
```
Sources: [apps/web/lib/auth/index.ts:1-6](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/index.ts#L1-L6)
> [!NOTE]
> The authentication barrel file acts as a single entry point for all sub-auth components, unifying workspace permission checks, session management, and token hashing into a consolidated namespace.
Sources: [apps/web/lib/auth/index.ts:1-6](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/index.ts#L1-L6)
### Legacy Backwards-Compatibility Routing Proxies
Legacy API routing under `apps/web/app/api/(old)/projects/` provides backwards-compatibility proxies that forward historical project-level API routes to modern workspace and domain implementations.
The base projects endpoint re-exports workspace routing handlers directly:
```ts
export * from "../../workspaces/route";
```
Sources: [apps/web/app/api/old/projects/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/route.ts#L1-L1)
Similarly, individual project slug routing proxies map project parameter endpoints to parameterized workspace routes:
```ts
export * from "../../../workspaces/[idOrSlug]/route";
```
Sources: [apps/web/app/api/old/projects/slug/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/route.ts#L1-L1)
### Domain and Link Information Proxies
Domain and link info routes under the legacy project hierarchy forward requests to canonical domain and link handlers. The domain collection route exports from the standard domains endpoint:
```ts
export * from "../../../../domains/route";
```
Sources: [apps/web/app/api/old/projects/slug/domains/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/route.ts#L1-L1)
Default domain configuration and specific domain routing proxies delegate directly to corresponding domain handlers:
```ts
export * from "../../../../../domains/default/route";
```
Sources: [apps/web/app/api/old/projects/slug/domains/default/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/default/route.ts#L1-L1)
```ts
export * from "../../../../../domains/[domain]/route";
```
Sources: [apps/web/app/api/old/projects/slug/domains/domain/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/%5Bdomain%5D/route.ts#L1-L1)
Link information routing follows the same delegation pattern, forwarding requests from the legacy project path to canonical link info endpoints:
```ts
export * from "../../../../../links/info/route";
```
Sources: [apps/web/app/api/old/projects/slug/links/info/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/links/info/route.ts#L1-L1)
| Legacy Route Path | Target Implementation Module | Purpose |
| :--- | :--- | :--- |
| `apps/web/app/api/(old)/projects/route.ts` | `../../workspaces/route` | Proxies project collection API calls to workspace collection handlers |
| `apps/web/app/api/(old)/projects/[slug]/route.ts` | `../../../workspaces/[idOrSlug]/route` | Proxies single project slug queries to workspace identifier handlers |
| `apps/web/app/api/(old)/projects/[slug]/domains/route.ts` | `../../../../domains/route` | Proxies project domain lists to canonical domain endpoints |
| `apps/web/app/api/(old)/projects/[slug]/domains/default/route.ts` | `../../../../../domains/default/route` | Proxies default domain requests |
| `apps/web/app/api/(old)/projects/[slug]/domains/[domain]/route.ts` | `../../../../../domains/[domain]/route` | Proxies specific domain management requests |
| `apps/web/app/api/(old)/projects/[slug]/links/info/route.ts` | `../../../../../links/info/route` | Proxies link information queries under legacy project namespaces |
Sources: [apps/web/app/api/old/projects/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/route.ts#L1-L1), [apps/web/app/api/old/projects/slug/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/route.ts#L1-L1), [apps/web/app/api/old/projects/slug/domains/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/route.ts#L1-L1), [apps/web/app/api/old/projects/slug/domains/default/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/default/route.ts#L1-L1), [apps/web/app/api/old/projects/slug/domains/domain/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/domains/%5Bdomain%5D/route.ts#L1-L1), [apps/web/app/api/old/projects/slug/links/info/route.ts:1-1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/(old)/projects/%5Bslug%5D/links/info/route.ts#L1-L1)
## Shared Internal Packages
### Overview
The monorepo maintains core shared capabilities under dedicated internal packages in the `packages/` directory. These packages abstract common UI primitives, low-level utility functions, and transactional email infrastructure across web applications and micro-apps. Each package is independently versioned, utilizes `tsup` for compilation to ESM and CJS formats, and declares workspace peer dependencies to ensure consistent React and Next.js runtimes.
Sources: [packages/ui/package.json:1-133](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json#L1-L133), [packages/email/package.json:1-55](https://github.com/blade47/dub/blob/HEAD/packages/email/package.json#L1-L55), [packages/utils/package.json:1-62](https://github.com/blade47/dub/blob/HEAD/packages/utils/package.json#L1-L62)
### UI Components Package (`@dub/ui`)
The `@dub/ui` package provides the design system components, icons, and chart utilities used throughout the Dub interface. It exposes three primary entry points in its `exports` map: the root package, `./icons`, and `./charts`.
```json
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"import": "./dist/icons/index.mjs",
"require": "./dist/icons/index.js"
},
"./charts": {
"types": "./dist/charts/index.d.ts",
"import": "./dist/charts/index.mjs",
"require": "./dist/charts/index.js"
}
},
```
Sources: [packages/ui/package.json:12-28](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json#L12-L28)
The package relies heavily on primitive libraries and headless UI components. Its dependency graph includes Floating UI for positioning, Radix UI primitives for accessible overlays and controls, Tiptap extensions for rich-text editing, and Visx packages combined with D3 arrays for charting.
| Dependency Category | Libraries / Packages | Purpose |
| :--- | :--- | :--- |
| **Overlays & Radix Primitives** | `@radix-ui/react-accordion`, `@radix-ui/react-dialog`, `@radix-ui/react-popover`, `@radix-ui/react-tooltip`, `vaul` | Accessible modals, popovers, drawers, and tooltips |
| **Rich Text Editor** | `@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-table`, `@tiptap/extension-link` | Advanced document editing and markdown manipulation |
| **Data Visualization** | `@visx/axis`, `@visx/shape`, `@visx/tooltip`, `d3-array` | Custom SVG charts, analytics graphs, and scales |
| **Styling & Utilities** | `class-variance-authority`, `tailwind-merge`, `motion`, `lucide-react` | Conditional class composition, animations, and icons |
Sources: [packages/ui/package.json:57-115](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json#L57-L115)
### Utility Functions Package (`@dub/utils`)
The `@dub/utils` package houses shared helper functions and constants exported from a centralized barrel file. Its source index exports all declarations from constants and functions modules:
```ts
export * from "./constants";
export * from "./functions";
```
Sources: [packages/utils/src/index.ts:1-3](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/index.ts#L1-L3)
The utility package consumes targeted helper libraries to perform slugification, date parsing, unique ID generation, and class name merging.
| Package Name | Version | Primary Utility Function |
| :--- | :--- | :--- |
| `@sindresorhus/slugify` | `^2.2.1` | Converts strings into clean URL slugs |
| `chrono-node` | `2.7.5` | Natural language date parser |
| `nanoid` | `^5.0.1` | URL-safe unique string ID generator |
| `tailwind-merge` | `^2.4.0` | Merges Tailwind CSS classes without conflict |
| `ms` | `^2.1.3` | Millisecond conversion utility |
| `punycode` | `^2.3.0` | Punycode domain name translator |
Sources: [packages/utils/package.json:34-42](https://github.com/blade47/dub/blob/HEAD/packages/utils/package.json#L34-L42)
### Email Infrastructure Package (`@dub/email`)
The `@dub/email` package manages transactional email templates and delivery pipelines using React Email and Resend or Nodemailer. It exposes granular exports for root utilities, template files, Resend wrappers, and Nodemailer transport:
```json
"exports": {
".": {
"import": "./src/index.ts",
"require": "./src/index.ts"
},
"./templates/*": {
"import": "./src/templates/*.tsx",
"require": "./src/templates/*.tsx"
},
"./resend": {
"import": "./src/resend/index.ts",
"require": "./src/resend/index.ts"
},
"./resend/*": {
"import": "./src/resend/*.ts",
"require": "./src/resend/*.ts"
},
"./send-via-nodemailer": {
"import": "./src/send-via-nodemailer.ts",
"require": "./src/send-via-nodemailer.ts"
}
}
```
Sources: [packages/email/package.json:33-54](https://github.com/blade47/dub/blob/HEAD/packages/email/package.json#L33-L54)
> [!TIP]
> Run `pnpm dev` inside `packages/email` to launch the React Email preview server locally on port `3333` targeting `./src/templates`.
Sources: [packages/email/package.json:6-9](https://github.com/blade47/dub/blob/HEAD/packages/email/package.json#L6-L9)
## Embeds and External Micro-Apps
### Overview
External integrations and embeddable SDKs within the monorepo comprise specialised micro-apps and client-facing packages. These include the HubSpot application package, the Stripe development configuration, and the vanilla JavaScript dashboard embedding core (`@dub/embed-core`).
Sources: [packages/hubspot-app/package.json:1-23](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/package.json#L1-L23), [packages/stripe-app/stripe-app.dev.json:1-3](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.dev.json#L1-L3), [packages/embeds/core/package.json:1-45](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/package.json#L1-L45)
### Embed Core Package (`@dub/embed-core`)
The `@dub/embed-core` package provides a vanilla JavaScript core script for embedding Dub's dashboards into external web pages. Its primary export entry point aggregates constants, core embedding logic, and type definitions through a central barrel file:
```ts
export * from "./constants";
export * from "./core";
export * from "./types";
```
Sources: [packages/embeds/core/src/index.ts:1-3](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/index.ts#L1-L3)
The package relies on Floating UI for positioning popups or floating elements within embedded contexts, targeting Node environments alongside browser targets using tsup for bundling:
| Property / Field | Setting / Value |
| :--- | :--- |
| **Package Name** | `@dub/embed-core` |
| **Main Entry** | `./dist/index.js` |
| **Module Entry** | `./dist/index.mjs` |
| **Types Entry** | `./dist/index.d.ts` |
| **Dependencies** | `@floating-ui/dom` (`^1.6.12`) |
Sources: [packages/embeds/core/package.json:2-21](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/package.json#L2-L21)
> [!NOTE]
> The `@dub/embed-core` package is marked with `"sideEffects": false` to allow aggressive tree-shaking during bundler optimization.
Sources: [packages/embeds/core/package.json:6-6](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/package.json#L6-L6)
### HubSpot and Stripe Integration Apps
The `dub-hubspot-app` package configures the HubSpot integration workspace. It specifies a private module configuration requiring Node `>=14`, wrapping the official HubSpot CLI runner script:
```json
"scripts": {
"hs": "hs"
},
"dependencies": {
"@hubspot/cli": "^7.6.2"
}
```
Sources: [packages/hubspot-app/package.json:5-12](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/package.json#L5-L12)
Similarly, the Stripe integration package (`packages/stripe-app`) maintains development configuration overriding via its base extension schema:
```json
{
"extends": "stripe-app.json"
}
```
Sources: [packages/stripe-app/stripe-app.dev.json:1-3](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.dev.json#L1-L3)
## Related
- [[Overview]]
- [[Quick Start]]
- [[UI Component Library]]
---
## Technical docs: GET Get admin commissions data
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getadmincommissions
## Responses
## Try It
---
## Technical docs: Routing and Multitenancy
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/routing-and-multitenancy
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/lib/middleware/app.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts)
- [apps/web/app/api/domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/api/domains/domain/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts)
- [apps/web/lib/middleware/admin.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts)
- [apps/web/lib/middleware/partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts)
- [apps/web/lib/middleware/embed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/embed.ts)
- [apps/web/app/domain/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx)
- [apps/web/lib/middleware/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/api.ts)
- [apps/web/app/app.dub.co/marketplace/...segments/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/%5B%5B...segments%5D%5D/page.tsx)
- [apps/web/app/domain/notfound/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx)
- [packages/utils/src/constants/main.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/domains/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/domains/page-client.tsx)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/next.config.js](https://github.com/blade47/dub/blob/HEAD/apps/web/next.config.js)
- [apps/web/lib/middleware/utils/parse.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/parse.ts)
- [apps/web/app/sitemap.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts)
- [apps/web/app/domain/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx)
- [packages/utils/src/constants/middleware.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/middleware.ts)
- [apps/web/lib/middleware/workspaces.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts)
- [apps/web/app/api/domains/default/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts)
- [apps/web/app/app.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/layout.tsx)
- [apps/web/lib/middleware/create-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/create-link.ts)
- [apps/web/ui/program-marketplace/marketplace-router.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-router.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/program/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/page.tsx)
- [apps/web/app/ee/partners.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/layout.tsx)
- [apps/web/app/app.dub.co/redirects/slug/domains/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(redirects)/%5Bslug%5D/domains/page.tsx)
## Overview
Dub employs a sophisticated edge-based routing and multitenancy architecture built on Next.js middleware and App Router zones. This system handles global request interception, hostname classification, tenant isolation across specialized subdomains, and dynamic custom domain link resolution. By routing requests efficiently at the edge, Dub separates core application dashboards, partner portals, administrative interfaces, and short link redirection flows while maintaining high-performance caching and robust security boundaries. Sources: [apps/web/middleware.ts:34-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L89), [apps/web/lib/middleware/link.ts:43-224](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L224), [apps/web/lib/middleware/app.ts:26-130](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L26-L130), [apps/web/lib/middleware/admin.ts:8-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L8-L38), [apps/web/lib/middleware/partners.ts:25-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L25-L122)
## Edge Middleware Dispatcher and Request Parsing
### Edge Middleware Dispatcher and Request Parsing
The Next.js edge middleware acts as the global request interception and routing dispatcher for Dub. Configured to run on the `nodejs` runtime with an explicit path matcher, the middleware excludes internal Next.js routes (`/_next/`), proxies (`/_proxy/`), API routes (`/api/`), and static metadata files (`favicon.ico`, `sitemap.xml`, `robots.txt`, `manifest.webmanifest`) while intercepting all other inbound traffic.
Sources: [apps/web/middleware.ts:20-32](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L20-L32)
### Request Parsing and Normalization
When a request arrives at `middleware()`, it is first processed by the `parse(req)` utility function. This function extracts the host header and pathname, normalizes the domain by removing `www.` prefixes and converting characters to lowercase, and resolves domain aliases via `DOMAIN_REDIRECTS`. It also checks for End-to-End (E2E) redirect test requests—identifying local `dub.localhost:8888` environments or preview deployments carrying the `x-e2e-redirect-test` header—and routes them to test or short domains accordingly. Finally, query parameters and decoded URL segments (`key` and `fullKey`) are extracted before returning a standardized parsed context object.
Sources: [apps/web/middleware.ts:35-35](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L35-L35), [apps/web/lib/middleware/utils/parse.ts:1-55](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/parse.ts#L1-L55)
```typescript
export const parse = (req: NextRequest) => {
let domain = req.headers.get("host") as string;
let path = req.nextUrl.pathname;
domain = domain.replace(/^www./, "").toLowerCase();
if (DOMAIN_REDIRECTS[domain]) {
domain = DOMAIN_REDIRECTS[domain];
}
const key = decodeURIComponent(path.split("/")[1]);
const fullKey = decodeURIComponent(path.slice(1));
return { domain, path, key, fullKey, ... };
};
```
Sources: [apps/web/lib/middleware/utils/parse.ts:5-54](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/parse.ts#L5-L54)
### Routing Dispatcher Execution Flow
Once parsing completes, the dispatcher executes logging via Axiom (`logger.info` and `ev.waitUntil(logger.flush())`) and evaluates the normalized domain and path against a sequential series of conditional branches to hand off execution to specialized middleware handlers.
```
middleware(req, ev)
→ parse(req)
→ isAppHostname(domain) ? AppMiddleware(req)
→ API_HOSTNAMES.has(domain) ? ApiMiddleware(req)
→ path.startsWith("/stats/") ? NextResponse.rewrite(...)
→ path.startsWith("/.well-known/") ? NextResponse.rewrite(...)
→ domain === "dub.sh" && DEFAULT_REDIRECTS[key] ? NextResponse.redirect(...)
→ ADMIN_HOSTNAMES.has(domain) ? AdminMiddleware(req)
→ PARTNERS_HOSTNAMES.has(domain) ? PartnersMiddleware(req)
→ isValidUrl(fullKey) ? CreateLinkMiddleware(req)
→ LinkMiddleware(req, ev)
```
Sources: [apps/web/middleware.ts:34-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L89)
### Hostname Constants and Default Redirects
The dispatcher relies on centralized collections of hostnames and default short-domain redirection rules defined across utility constants.
| Hostname Constant / Rule | Target / Value Set | Purpose |
| :--- | :--- | :--- |
| `API_HOSTNAMES` | `api.dub.co`, `api-staging.dub.co`, `api.dub.sh`, `api.localhost:8888`, `api.localhost` | Identifies API server requests for `ApiMiddleware`. |
| `ADMIN_HOSTNAMES` | `admin.dub.co`, `admin.localhost:8888`, `admin.localhost` | Identifies internal administration requests for `AdminMiddleware`. |
| `PARTNERS_HOSTNAMES` | `partners.dub.co`, `partners-staging.dub.co`, `partners.localhost:8888`, `partners.localhost` | Identifies partner portal requests for `PartnersMiddleware`. |
| `DEFAULT_REDIRECTS` | `home`, `dub`, `signin`, `login`, `register`, `signup`, `app`, `dashboard`, `links`, `settings`, `welcome`, `discord` | Provides fallback shortcut redirects on the `dub.sh` short domain. |
Sources: [packages/utils/src/constants/main.ts:3-29](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts#L3-L29), [packages/utils/src/constants/middleware.ts:1-14](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/middleware.ts#L1-L14)
> [!NOTE]
> Preview environments evaluate `isAppHostname` by verifying whether the hostname starts with `dub-` and ends with `.dub.co`, whereas production environments strictly match against `app.dub.co`, `localhost:8888`, and `localhost`.
Sources: [packages/utils/src/constants/main.ts:59-65](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/main.ts#L59-L65)
## Application and Workspace Subdomain Routing
### Overview
The `AppMiddleware` function manages traffic arriving on `app.dub.co` and application subdomains. It processes incoming requests by parsing URL attributes, authenticating user sessions via tokens, managing onboarding flows for newly registered accounts, resolving default workspaces, and orchestrating rewrites or redirects into the Next.js App Router layout.
Sources: [apps/web/lib/middleware/app.ts:26-130](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L26-L130), [apps/web/app/app.dub.co/layout.tsx:1-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/layout.tsx#L1-L12)
### Authentication and Public Path Interception
When a request enters `AppMiddleware`, it parses the request components and inspects embed paths, bypassing authentication for predefined public routes or redirecting unauthenticated users to `/login`.
```
AppMiddleware(req)
→ parse(req)
→ path.startsWith("/embed") ? EmbedMiddleware(req)
→ getUserViaToken(req)
→ (!user && !isPublicPath(path)) ? NextResponse.redirect(/login?next=...)
→ user && !isPublicPath(path) ? [Onboarding or Workspace Routing]
```
Sources: [apps/web/lib/middleware/app.ts:26-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L26-L52)
Public paths that bypass authentication checks are verified via the `isPublicPath` helper function, which matches exact paths and path prefixes.
| Path Match Type | Target Paths and Prefixes | Purpose |
| :--- | :--- | :--- |
| Exact Match | `/marketplace` | Allows public access to marketplace listings. |
| Prefix Match | `/marketplace/`, `/share/`, `/deeplink/`, `/unsubscribe/`, `/auth/reset-password/` | Permits unauthenticated handling for shared links, redirects, preference management, and password resets. |
Sources: [apps/web/lib/middleware/app.ts:16-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L16-L24)
### Onboarding Redirect Logic
For authenticated users within the onboarding window (`ONBOARDING_WINDOW_SECONDS`), the middleware evaluates whether the user has a default workspace, pending project invites, or a completed onboarding status via `onboardingStepCache`.
```
User creation check (createdAt < ONBOARDING_WINDOW_SECONDS)
→ path not in [/onboarding, /account, /workspaces]
→ !(getDefaultWorkspace(user))
→ !(hasPendingInvites(req, user))
→ onboardingStepCache != "completed"
→ step missing? → /onboarding
→ step == "completed" → WorkspacesMiddleware(req, user)
→ defaultWorkspace exists? → /onboarding/${step}?workspace=${defaultWorkspace}
→ else → /onboarding
```
Sources: [apps/web/lib/middleware/app.ts:65-91](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L65-L91)
> [!WARNING]
> The onboarding window condition evaluates `new Date(user.createdAt).getTime() > Date.now() - ONBOARDING_WINDOW_SECONDS * 1000`. If a user was created outside this active timeframe, onboarding redirects are bypassed in favor of standard workspace resolution.
Sources: [apps/web/lib/middleware/app.ts:66-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L66-L67)
### Workspace Tenant Resolution (`WorkspacesMiddleware`)
When requests target core application dashboard routes (`/`, `/links`, `/analytics`, `/events`, `/upgrade`, `/guides`, `/wrapped`, `/programs`, `/program`, `/customers`, `/settings`) or when onboarding completes, control passes to `WorkspacesMiddleware`.
```typescript
export async function WorkspacesMiddleware(req: NextRequest, user: UserProps) {
const { path, searchParamsObj, searchParamsString } = parse(req);
if (
searchParamsObj.next &&
isValidInternalRedirect({
redirectPath: searchParamsObj.next,
currentUrl: req.url,
})
) {
return NextResponse.redirect(new URL(searchParamsObj.next, req.url));
}
const defaultWorkspace = await getDefaultWorkspace(user);
if (defaultWorkspace) {
let redirectPath = path;
if (["/", "/login", "/register"].includes(path)) {
redirectPath = "";
} else if (isTopLevelSettingsRedirect(path)) {
redirectPath = `/settings/${path}`;
}
if (!redirectPath) {
const product = await getWorkspaceProduct(defaultWorkspace);
redirectPath = `/${product}`;
}
return NextResponse.redirect(
new URL(
`/${defaultWorkspace}${redirectPath}${searchParamsString}`,
req.url,
),
);
}
const projectInvite = await prismaEdge.projectInvite.findFirst({
where: { email: user.email },
select: { project: { select: { slug: true } } },
});
if (projectInvite) {
return NextResponse.redirect(
new URL(`/${projectInvite.project.slug}/invite`, req.url),
);
}
return NextResponse.redirect(new URL("/onboarding/workspace", req.url));
}
```
Sources: [apps/web/lib/middleware/workspaces.ts:10-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts#L10-L70)
> [!TIP]
> `WorkspacesMiddleware` checks `searchParamsObj.next` using `isValidInternalRedirect` before routing users to their default tenant workspace, preventing open redirect vulnerabilities across internal application routes.
Sources: [apps/web/lib/middleware/workspaces.ts:13-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts#L13-L22)
### Program Onboarding Sub-Step Verification
During the onboarding sequence, the step page for program configuration (`ProgramPage`) validates workspace ownership and user session tokens before rendering client components.
```typescript
export default async function ProgramPage({
searchParams,
}: {
searchParams: Promise<{ workspace?: string }>;
}) {
const { workspace: slug } = await searchParams;
if (!slug) redirect("/onboarding");
const { user } = await getSession();
const workspace = await prisma.project.findUniqueOrThrow({
where: {
slug,
users: {
some: {
userId: user.id,
},
},
},
select: {
id: true,
domains: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
const data = await redis.get<{ domain: string; userId: string }>(
`onboarding-domain:${workspace.id}`,
);
const onboardingDomain =
data && data.domain && data.userId === user.id ? data.domain : null;
const domain = onboardingDomain || workspace.domains[0]?.slug;
if (!domain)
redirect(`/onboarding/domain?workspace=${slug}&product=partners`);
return ;
}
```
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/program/page.tsx:7-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/page.tsx#L7-L51)
## Partners and Admin Subdomain Isolation
### Partners and Admin Subdomain Isolation
Subdomain isolation for administrative tools and partner portals is managed by dedicated edge middleware functions. These intercept requests destined for restricted domains, evaluate authorization credentials against edge database records, and enforce role-based access before rewriting or redirecting traffic.
### Administrative Middleware Execution (`AdminMiddleware`)
The `AdminMiddleware` function handles incoming requests targeting administrative boundaries. It parses the request URL, retrieves user authentication tokens via `getUserViaToken`, and enforces strict project-membership checks against the `DUB_WORKSPACE_ID` constant using `prismaEdge`.
```typescript
export async function AdminMiddleware(req: NextRequest) {
const { path } = parse(req);
const user = await getUserViaToken(req);
if (!user && path !== "/login") {
return NextResponse.redirect(new URL("/login", req.url));
} else if (user) {
const isAdminUser = await prismaEdge.projectUsers.findUnique({
where: {
userId_projectId: {
userId: user.id,
projectId: DUB_WORKSPACE_ID,
},
},
});
if (!isAdminUser) {
return NextResponse.next(); // throw 404 page
} else if (
path === "/login" ||
!canAccessAdminPath({ userId: user.id, pathname: path })
) {
return NextResponse.redirect(new URL("/", req.url));
}
}
return NextResponse.rewrite(
new URL(`/admin.dub.co${path === "/" ? "" : path}`, req.url),
);
}
```
Sources: [apps/web/lib/middleware/admin.ts:8-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L8-L38)
> [!CAUTION]
> If a user is authenticated but lacks membership in the designated admin project (`DUB_WORKSPACE_ID`), `AdminMiddleware` returns `NextResponse.next()` which results in a 404 error page rather than an authorization error, concealing the existence of the admin portal.
Sources: [apps/web/lib/middleware/admin.ts:16-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L16-L26)
### Partner Portal Routing and Middleware (`PartnersMiddleware`)
The partner portal middleware governs access to program discovery, affiliate links, and earnings management on `partners.dub.co`. It enforces authentication on specific paths defined by `AUTHENTICATED_PATHS` and manages redirection flows based on partner profile status.
```typescript
const AUTHENTICATED_PATHS = [
"/programs",
"/marketplace",
"/onboarding",
"/settings",
"/profile",
"/messages",
"/payouts",
"/account",
"/invite",
"/rewind",
];
```
Sources: [apps/web/lib/middleware/partners.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L12-L23)
When an unauthenticated request targets an authenticated path, `PartnersMiddleware` intercepts the request. If the path targets a specific program route like `/programs/`, it extracts the program slug to route the user directly to that program's login page; otherwise, it appends a `?next=` query parameter pointing to the original destination.
```typescript
if (!user && isAuthenticatedPath) {
if (path.startsWith("/programs/")) {
const programSlug = path.split("/")[2];
return NextResponse.redirect(new URL(`/${programSlug}/login`, req.url));
}
return NextResponse.redirect(
new URL(
`/login${path === "/" ? "" : `?next=${encodeURIComponent(fullPath)}`}`,
req.url,
),
);
}
```
Sources: [apps/web/lib/middleware/partners.ts:63-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L63-L74)
> [!TIP]
> `PartnersMiddleware` validates internal redirects using `isValidInternalRedirect` when processing `?next=` query parameters, ensuring that open redirect attacks are mitigated before returning the response.
Sources: [apps/web/lib/middleware/partners.ts:93-102](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L93-L102)
### Marketplace Routing and Dynamic Layouts
The marketplace subdomain infrastructure relies on slug-based segmentation. The marketplace page loader processes URL segments dynamically via `generateMetadata` and `MarketplaceRouter`, supporting categories and individual program listings.
```typescript
export function MarketplaceRouter({ segments = [] }: { segments?: string[] }) {
if (segments.length === 0) {
return (
);
}
if (segments.length === 1 && segments[0] === "all") {
return } />;
}
if (segments.length === 2 && segments[0] === "c") {
const category = slugToCategory(segments[1]);
if (category) {
return } />;
}
}
if (segments.length === 1) {
return ;
}
notFound();
}
```
Sources: [apps/web/ui/program-marketplace/marketplace-router.tsx:38-66](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-router.tsx#L38-L66)
The partners layout wraps client children within NextAuth's `SessionProvider` alongside a React `Suspense` boundary to preserve session state across partner routes.
```typescript
export default function PartnersLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
Sources: [apps/web/app/ee/partners.dub.co/layout.tsx:1-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/layout.tsx#L1-L12)
## Custom Domain Link Resolution and Rewriting
### Overview
The custom domain link resolution and rewriting pipeline executes within `LinkMiddleware`, governing incoming requests across custom and shortened domains. It handles normalization, case sensitivity checks, inspection mode toggles, edge cache lookups, database fallbacks, Bitly crawls, and click identity minting before executing redirects or rewrites.
Sources: [apps/web/lib/middleware/link.ts:43-224](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L224)
### Request Lifecycle and Resolution Call Chain
When a request arrives at `LinkMiddleware`, it proceeds through a strict sequence of validation and lookup stages. The call chain runs as follows: `parse(req)` → `punyEncode()` → `isCaseSensitiveDomain()` → `isUnsupportedKey()` → `linkCache.get()` → `getLinkViaEdge()` → `formatRedisLink()` → `resolveABTestURL()` → click identifier minting.
Sources: [apps/web/lib/middleware/link.ts:44-211](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L44-L211)
```typescript
export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) {
let { domain, fullKey: originalKey, fullPath, searchParamsObj } = parse(req);
if (!domain) {
return NextResponse.next();
}
let key = punyEncode(originalKey);
if (!isCaseSensitiveDomain(domain)) {
key = key.toLowerCase();
}
...
```
Sources: [apps/web/lib/middleware/link.ts:43-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L56)
> [!NOTE]
> Links on Dub are case-insensitive by default. When `isCaseSensitiveDomain(domain)` returns false, keys are automatically lowercased after puny-encoding, whereas case-sensitive domains preserve original casing.
Sources: [apps/web/lib/middleware/link.ts:50-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L50-L56)
### Bitly Crawl Fallback
When a link lookup via edge storage (`getLinkViaEdge`) misses and the target domain is explicitly `buff.ly`, the middleware invokes `crawlBitly`. This utility queries the Bitlink API, validates key characters against Bitly's unsupported character rules, and creates links on-demand in the database under a dedicated buffer workspace.
Sources: [apps/web/lib/middleware/link.ts:100-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L100-L109), [apps/web/lib/middleware/utils/crawl-bitly.ts:20-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L68)
```typescript
const BUFFER_WORKSPACE_ID = "cm05wnnpo000711ztj05wwdbu";
const BUFFER_USER_ID = "cm05wnd49000411ztg2xbup0i";
const BUFFER_FOLDER_ID = "fold_1JNQBVZV8P0NA0YGB11W2HHSQ";
const BUFFER_BITLY_API_KEY = process.env.BUFFER_BITLY_API_KEY;
export const crawlBitly = async (req: NextRequest, ev: NextFetchEvent) => {
const { domain, fullKey: key } = parse(req);
const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$ÂŁ?%#|]/;
if (key && !invalidBitlyKeyRegex.test(key)) {
const link = await fetchBitlyLink({ domain, key });
if (link) {
const sanitizedUrl = getUrlFromStringIfValid(link.long_url);
if (sanitizedUrl) {
ev.waitUntil(
prisma.link.create({
data: {
id: createId({ prefix: "link_" }),
domain,
key: encodeKeyIfCaseSensitive({ domain, key }),
url: sanitizedUrl,
shortLink: linkConstructorSimple({ domain, key }),
projectId: BUFFER_WORKSPACE_ID,
userId: BUFFER_USER_ID,
folderId: BUFFER_FOLDER_ID,
createdAt: new Date(link.created_at),
},
}),
);
}
return NextResponse.redirect(link.long_url, { headers: DUB_HEADERS, status: 302 });
}
}
return NextResponse.redirect("https://buffer.com", { status: 302 });
};
```
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L81)
> [!WARNING]
> If a Bitly lookup hits a rate limit or returns a non-OK response status, `fetchBitlyLink` logs the rate limit event and returns `null`, causing `crawlBitly` to fall back to redirecting users to `https://buffer.com`.
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:71-105](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L71-L105)
### Design Choices in Link Resolution
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Redis & Edge Caching (`linkCache`)** | Minimizes database queries for high-throughput link redirection | Requires background revalidation and cache synchronization handling |
| **Redis Failover Detection** | Prevents request timeouts and cascading failures during Redis outages | Bypasses click tracking and cookie persistence temporarily |
| **Inspect Mode (`+` suffix)** | Enables quick diagnostics and metadata inspection without redirecting | Requires parsing and stripping trailing characters from keys |
| **On-Demand Bitly Crawling** | Automatically migrates and provisions legacy links on `buff.ly` | Depends on external API rate limits and upstream availability |
Sources: [apps/web/lib/middleware/link.ts:58-98](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L58-L98), [apps/web/lib/middleware/link.ts:124-148](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L124-L148), [apps/web/lib/middleware/utils/crawl-bitly.ts:20-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L111)
## App Router Directory Structure Multitenancy
### Overview
Dub's App Router directory structure implements multi-zone and multitenant routing across custom domains, application subdomains, and partner subdomains. Dynamic route groups handle custom domains via `[domain]`, while legacy workspace paths utilize redirects to enforce clean URL structures.
Sources: [apps/web/app/domain/page.tsx:1-86](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx#L1-L86), [apps/web/app/app.dub.co/redirects/slug/domains/page.tsx:1-10](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(redirects)/%5Bslug%5D/domains/page.tsx#L1-L10)
### Custom Domain Layout and Rendering
The `[domain]` directory handles requests landing on user-owned custom domains. The layout component (`apps/web/app/[domain]/layout.tsx`) wraps all external custom pages in a flexible column container configured with standard navigation elements (`NavMobile`, `Nav`) and a page footer (`Footer`).
Sources: [apps/web/app/domain/layout.tsx:1-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx#L1-L16)
```typescript
export default function ExternalPagesLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
```
Sources: [apps/web/app/domain/layout.tsx:3-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx#L3-L16)
The root page (`apps/web/app/[domain]/page.tsx`) disables static generation parameters (`generateStaticParams` returns `[]`) and caches responses indefinitely via `export const revalidate = false`. Metadata is dynamically constructed using `generateMetadata`, embedding the uppercase domain name into the page title and description.
Sources: [apps/web/app/domain/page.tsx:11-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx#L11-L28)
```typescript
export async function generateMetadata(props: {
params: Promise<{ domain: string }>;
}) {
const params = await props.params;
const title = `${params.domain.toUpperCase()} - A Dub Custom Domain`;
const description = `${params.domain.toUpperCase()} is a custom domain on Dub - the modern link attribution platform for short links, conversion tracking, and affiliate programs.`;
return constructMetadata({
title,
description,
});
}
```
Sources: [apps/web/app/domain/page.tsx:13-24](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx#L13-L24)
> [!NOTE]
> When a custom domain link lookup fails or points to a non-existent path, `NotFoundLinkPage` queries Prisma for `domainData`. If a custom `notFoundUrl` is configured on the domain model, the request immediately redirects to that target URL.
Sources: [apps/web/app/domain/notfound/page.tsx:31-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L31-L43)
### Sitemap and Workspace Redirects
The sitemap generator (`apps/web/app/sitemap.ts`) inspects incoming request host headers to differentiate between standard domains, partner hostnames (`PARTNERS_HOSTNAMES`), and core application hostnames (`isAppHostname`). When handling partner domains, it queries published program lander data to construct partner sitemap entries.
Sources: [apps/web/app/sitemap.ts:11-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts#L11-L44)
```typescript
if (PARTNERS_HOSTNAMES.has(domain)) {
const programs = await prisma.program.findMany({
where: {
groups: {
some: {
slug: "default",
landerData: {
not: Prisma.AnyNull,
},
landerPublishedAt: {
not: null,
},
},
},
},
orderBy: {
slug: "asc",
},
});
return programs.map((program) => ({
url: `https://partners.dub.co/${program.slug}`,
lastModified: new Date(),
}));
}
```
Sources: [apps/web/app/sitemap.ts:20-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts#L20-L44)
Legacy workspace domain routes under `app.dub.co` are automatically normalized through redirect handlers. For instance, `OldWorkspaceDomains` intercepts requests to `/[slug]/domains` and permanently redirects clients to the updated settings path.
Sources: [apps/web/app/app.dub.co/redirects/slug/domains/page.tsx:1-10](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(redirects)/%5Bslug%5D/domains/page.tsx#L1-L10)
```typescript
export default async function OldWorkspaceDomains(props: {
params: Promise<{
slug: string;
}>;
}) {
const params = await props.params;
redirect(`/${params.slug}/settings/domains`);
}
```
Sources: [apps/web/app/app.dub.co/redirects/slug/domains/page.tsx:3-10](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(redirects)/%5Bslug%5D/domains/page.tsx#L3-L10)
## Custom Domain Provisioning and Lifecycle
### Overview
Custom domain provisioning and lifecycle operations are handled through workspace-authenticated API endpoints and dashboard client components. The system manages domain records via Prisma transactions, coordinates provisioning with Vercel when running in production environments (`process.env.VERCEL === "1"`), and tracks default platform short domains assigned to individual workspaces.
Sources: [apps/web/app/api/domains/route.ts:23-173](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L23-L173), [apps/web/app/api/domains/default/route.ts:9-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L9-L82)
### Domain Creation and Vercel Integration
When a new domain is registered via `POST /api/domains`, the request body is parsed and validated against `createDomainBodySchemaExtended`. Free plan workspaces are restricted from configuring advanced branding parameters such as custom QR code logos, expiration URLs, not found URLs, Asset Links, Apple App Site Association, or Deep View configurations.
Sources: [apps/web/app/api/domains/route.ts:97-137](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L97-L137)
The lifecycle execution sequence for adding a domain follows a strict operational call-chain:
`parseRequestBody()` → `createDomainBodySchemaExtended.parseAsync()` → `validateDomain()` → `addDomainToVercel()` → `storage.upload()` → `prisma.$transaction()`
Sources: [apps/web/app/api/domains/route.ts:99-184](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L99-L184)
> [!NOTE]
> During Vercel domain provisioning (`addDomainToVercel`), if Vercel returns an error code equal to `domain_already_in_use`, the API explicitly ignores the error to allow multi-workspace or pre-existing DNS attachments to resolve gracefully.
Sources: [apps/web/app/api/domains/route.ts:164-173](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L164-L173)
### Default Domain Mappings and Workspace Settings
Workspaces can query and update their assigned default platform domains (such as `dub.sh`, `chatg.pt`, `spti.fi`, `git.new`, `cal.link`, `amzn.id`, `ggl.link`, and `fig.page`) using dedicated API routes.
Sources: [apps/web/app/api/domains/default/route.ts:1-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L1-L82)
| Default Domain Key | Domain Name | Database Column |
| :--- | :--- | :--- |
| `dubsh` | `dub.sh` | `dubsh` |
| `chatgpt` | `chatg.pt` | `chatgpt` |
| `sptifi` | `spti.fi` | `sptifi` |
| `gitnew` | `git.new` | `gitnew` |
| `callink` | `cal.link` | `callink` |
| `amznid` | `amzn.id` | `amznid` |
| `ggllink` | `ggl.link` | `ggllink` |
| `figpage` | `fig.page` | `figpage` |
Sources: [apps/web/app/api/domains/default/route.ts:18-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L18-L25), [apps/web/app/api/domains/default/route.ts:66-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L66-L73)
### Domain Lifecycle API Endpoints
The domain settings surface area exposes several endpoints for listing, updating, and removing domains within a workspace context.
| Endpoint Route | HTTP Method | Required Permission | Description |
| :--- | :--- | :--- | :--- |
| `/api/domains` | `GET` | `domains.read` | Retrieve all domains associated with the workspace with optional search, pagination, and link inclusion. |
| `/api/domains` | `POST` | `domains.write` | Add a new custom domain, enforce plan limits, and register with Vercel. |
| `/api/domains/[domain]` | `GET` | `domains.read` | Retrieve details for a specific workspace domain. |
| `/api/domains/[domain]` | `PATCH` | `domains.write` | Edit configuration, update redirection URLs, or archive a workspace domain. |
| `/api/domains/default` | `GET` | `domains.read` | Fetch enabled default platform short domains for the workspace. |
| `/api/domains/default` | `PATCH` | `domains.write` | Update active default platform short domains configuration. |
Sources: [apps/web/app/api/domains/route.ts:22-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L22-L94), [apps/web/app/api/domains/route.ts:96-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L96-L98), [apps/web/app/api/domains/domain/route.ts:28-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L28-L42), [apps/web/app/api/domains/domain/route.ts:44-46](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L44-L46), [apps/web/app/api/domains/default/route.ts:8-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L8-L48), [apps/web/app/api/domains/default/route.ts:54-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts#L54-L82)
> [!WARNING]
> Claiming a `.dub.link` subdomain enforces strict constraints: workspaces can claim at most one `.dub.link` subdomain, and non-onboarding requests require an active `defaultProgramId` or will throw a 403 forbidden error.
Sources: [apps/web/app/api/domains/route.ts:186-210](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L186-L210)
## Related
- [[Link Resolution and Redirection]]
- [[Custom Domains]]
---
## Technical docs: POST Refresh a domain on Vercel
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/refreshadmindomain
## Request Body
Domain name to refresh
## Responses
## Try It
---
## Technical docs: POST Register a premium .link domain
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/registerpremiumdomain
## Request Body
Domain and workspace slug
## Responses
## Try It
---
## Technical docs: Database Schema and Prisma
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/database-schema-and-prisma
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/prisma/schema/workspace.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma)
- [apps/web/prisma/schema/link.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma)
- [apps/web/app/ee/api/partners/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts)
- [apps/web/prisma/schema/program.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma)
- [apps/web/prisma/schema/commission.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma)
- [apps/web/prisma/schema/reward.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/reward.prisma)
- [apps/web/prisma/schema/schema.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/schema.prisma)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
- [apps/web/lib/planetscale/get-link-with-partner.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/referrals/page.tsx)
- [apps/web/prisma/schema/misc.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/misc.prisma)
- [apps/web/lib/commissions/process-click-aggregation.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts)
- [apps/web/lib/ai/get-program-performance.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/get-program-performance.ts)
- [apps/web/lib/partner-referrals/create-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts)
- [apps/web/lib/api/billing/recompute-workspace-usage.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts)
- [apps/web/prisma/schema/utm.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/utm.prisma)
- [apps/web/lib/fetchers/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/fetchers/index.ts)
- [apps/web/prisma/schema/group.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/group.prisma)
- [apps/web/scripts/dub-partner-rewind.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts)
- [apps/web/prisma/schema/domain.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/domain.prisma)
- [apps/web/prisma/schema/partner.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/partner.prisma)
- [apps/web/scripts/migrations/backfill-commissions-metadata.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts)
- [apps/web/prisma/schema/discount.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/discount.prisma)
- [apps/web/scripts/dev/seed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed-commissions.ts)
- [apps/web/lib/partnerstack/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts)
- [apps/web/lib/api/commissions/get-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts)
## Overview
Dub's database architecture is built around a multi-file Prisma ORM setup configured for a MySQL datasource, connecting core multi-tenant workspaces, domains, and redirect links with advanced partner program management, attribution, and analytics pipelines. Sources: [apps/web/prisma/schema/schema.prisma:1-9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/schema.prisma#L1-L9), [apps/web/prisma/schema/workspace.prisma:1-105](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L1-L105), [apps/web/prisma/schema/link.prisma:1-107](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L1-L107), [apps/web/prisma/schema/program.prisma:1-179](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L1-L179), [apps/web/prisma/schema/commission.prisma:1-87](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma#L1-L87), [apps/web/prisma/schema/reward.prisma:1-81](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/reward.prisma#L1-L81)
The schema addresses the complexities of distributed edge lookups, multi-tenant link shortening, and high-volume referral attribution by segregating schema logic across specialized domain files while maintaining strict relational constraints and indexing strategies for optimized read and write performance. Sources: [apps/web/prisma/schema/workspace.prisma:103-105](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L103-L105), [apps/web/prisma/schema/link.prisma:96-106](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L96-L106), [apps/web/prisma/schema/commission.prisma:73-86](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma#L73-L86)
## Datasource and Multi-File Prisma Architecture
### Overview
Dub organizes its database schema using a multi-file layout under `apps/web/prisma/schema/`, separating core domain models into dedicated files such as `schema.prisma`, `workspace.prisma`, `link.prisma`, and `misc.prisma`. Sources: [apps/web/prisma/schema/schema.prisma:1-9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/schema.prisma#L1-L9), [apps/web/prisma/schema/workspace.prisma:1-154](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L1-L154), [apps/web/prisma/schema/link.prisma:1-108](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L1-L108), [apps/web/prisma/schema/misc.prisma:1-36](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/misc.prisma#L1-L36)
### MySQL Datasource Configuration
The primary connection and client code generation are defined in `schema.prisma`, establishing a MySQL provider with an environment variable-backed connection URL and Prisma relation mode. Sources: [apps/web/prisma/schema/schema.prisma:1-9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/schema.prisma#L1-L9)
| Block / Directive | Type / Value | Default / Setting | Purpose |
| :--- | :--- | :--- | :--- |
| `datasource db` | provider | `"mysql"` | Configures Prisma to target a MySQL database backend. |
| `url` | env var | `env("DATABASE_URL")` | Specifies the connection string retrieved from environment configuration. |
| `relationMode` | string | `"prisma"` | Employs Prisma-emulated relations rather than foreign key constraints at the database engine level. |
| `generator client` | provider | `"prisma-client-js"` | Generates the type-safe JavaScript/TypeScript Prisma client library. |
Sources: [apps/web/prisma/schema/schema.prisma:1-9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/schema.prisma#L1-L9)
### Client Usage Patterns
Data fetching utilities across Dub leverage the generated Prisma client wrapped with React's `cache` utility to retrieve workspaces, default projects, and individual short links. Sources: [apps/web/lib/fetchers/index.ts:1-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/fetchers/index.ts#L1-L68)
> [!NOTE]
> Database fetchers such as `getDefaultWorkspace`, `getWorkspace`, and `getLink` integrate session verification with React caching to avoid redundant queries during request lifecycles. Sources: [apps/web/lib/fetchers/index.ts:5-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/fetchers/index.ts#L5-L67)
The following example demonstrates the execution flow of fetching a link record by its domain and key composite identifier using the Prisma client instance:
```typescript
export const getLink = cache(
async ({ domain, key }: { domain: string; key: string }) => {
return await prisma.link.findUnique({
where: {
domain_key: {
domain,
key,
},
},
});
},
);
```
Sources: [apps/web/lib/fetchers/index.ts:56-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/fetchers/index.ts#L56-L67)
## Workspace, Domain, and Link Models
### Workspace, Domain, and Link Models
### Overview
The core data architecture for multi-tenant URL shortening centers around the `Project` (workspace) model, which acts as the parent container for domains, short links, UTM templates, team memberships, and product usage limits. Individual short links belong to specific workspaces and map to verified custom or system domains, while UTM templates standardize parameter injection across campaigns. Sources: [apps/web/prisma/schema/workspace.prisma:13-105](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L13-L105), [apps/web/prisma/schema/link.prisma:1-107](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L1-L107), [apps/web/prisma/schema/domain.prisma:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/domain.prisma#L1-L28), [apps/web/prisma/schema/utm.prisma:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/utm.prisma#L1-L28)
### Workspace Usage and Plan Configurations
Workspaces configure billing tiers, subscription states, and quotas governing links, clicks, partner payouts, and AI features. The system tracks consumption against these limits to enforce billing policies and restrict over-quota operations. Sources: [apps/web/prisma/schema/workspace.prisma:13-61](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L13-L61)
| Field Name | Type | Default / Constraints | Purpose |
| :--- | :--- | :--- | :--- |
| `id` | String | `@id` | Unique identifier for the workspace project. |
| `slug` | String | `@unique` | URL-friendly unique handle for the workspace. |
| `plan` | String | `"free"` | Subscription plan identifier (e.g., enterprise, free). |
| `planTier` | Int | `1` | Numeric tier level associated with the current plan. |
| `billingCycleStart` | Int | (required) | Day of the month when the billing cycle begins. |
| `totalLinks` | Int | `0` | Total number of links currently registered in the workspace. |
| `totalClicks` | Int | `0` | Aggregate number of link clicks recorded across the workspace. |
| `usage` | Int | `0` | Current click consumption counter for billing periods. |
| `usageLimit` | Int | `1000` | Maximum click allowance permitted under the active plan. |
| `linksUsage` | Int | `0` | Number of links consumed against the link quota. |
| `linksLimit` | Int | `25` | Maximum link creation cap allowed for the workspace tier. |
| `domainsLimit` | Int | `3` | Maximum custom domains allowed to be linked to the workspace. |
| `usersLimit` | Int | `1` | Maximum number of team members permitted in the workspace. |
| `aiUsage` | Int | `0` | Current consumption count for AI-powered features. |
| `aiLimit` | Int | `10` | Maximum allowance for AI features per cycle. |
Sources: [apps/web/prisma/schema/workspace.prisma:13-61](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workspace.prisma#L13-L61)
> [!NOTE]
> Workspace usage recomputation executes asynchronously by aggregating raw event and invoice records over the active billing window defined by `billingCycleStart`. Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:8-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L8-L50)
### Usage Recomputation Call-Chain Walkthrough
The workspace billing and usage synchronization routine evaluates current consumption metrics by querying analytics stores and financial records in parallel. The execution path follows a strict function sequence:
1. `recomputeWorkspaceUsage()` receives the workspace object containing its `id` and `billingCycleStart` property. Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:8-10](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L8-L10)
2. `getBillingStartDate()` calculates the exact starting timestamp based on the billing cycle day. Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:3-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L3-L11)
3. `Promise.all()` concurrently invokes `getWorkspaceUsage()` twice (querying resource `"events"` for clicks and `"links"` for link creations) alongside `prisma.invoice.aggregate()` to sum completed partner payouts. Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:14-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L14-L43)
4. The helper function `sum()` processes the mapped metric arrays to produce aggregate totals for `usage`, `linksUsage`, and `payoutsUsage`. Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:6-49](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L6-L49)
```typescript
export async function recomputeWorkspaceUsage(
workspace: Pick,
) {
const billingStart = getBillingStartDate(workspace.billingCycleStart);
const billingEnd = new Date();
const [clicksData, linksData, payoutsUsage] = await Promise.all([
getWorkspaceUsage({
workspaceId: workspace.id,
resource: "events",
start: billingStart,
end: billingEnd,
}),
getWorkspaceUsage({
workspaceId: workspace.id,
resource: "links",
start: billingStart,
end: billingEnd,
}),
prisma.invoice.aggregate({
where: {
workspaceId: workspace.id,
type: "partnerPayout",
status: "completed",
paidAt: {
gte: billingStart,
lte: billingEnd,
},
},
_sum: {
amount: true,
},
}),
]);
return {
usage: sum(clicksData.map((d) => d.value)),
linksUsage: sum(linksData.map((d) => d.value)),
payoutsUsage: payoutsUsage._sum.amount ?? 0,
};
}
```
Sources: [apps/web/lib/api/billing/recompute-workspace-usage.ts:8-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/billing/recompute-workspace-usage.ts#L8-L50)
### Link, Domain, and UTM Schema Models
Short links store routing configurations, Open Graph metadata, A/B testing variants, expiration rules, and native UTM tracking parameters (`utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`). Domains regulate verification status and link retention policies, while UTM templates store reusable parameter sets for campaigns. Sources: [apps/web/prisma/schema/link.prisma:1-95](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L1-L95), [apps/web/prisma/schema/domain.prisma:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/domain.prisma#L1-L28), [apps/web/prisma/schema/utm.prisma:1-24](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/utm.prisma#L1-L24)
> [!WARNING]
> Short links do not index full target URLs directly; instead, they maintain a length-constrained index on `[projectId, url(length: 500)]` to support high-performance URL upsert and deduplication routines without violating database key length limitations. Sources: [apps/web/prisma/schema/link.prisma:99|99](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L99-L99)
## Edge Link Lookups and Caching
### Edge Link Lookups and Caching
### Overview
Edge link resolution in Dub relies on high-performance caching layers and direct database queries executed via PlanetScale. When an incoming request reaches the edge middleware, the system parses the hostname and URL path to isolate the target domain and link key. Depending on whether the domain is configured for case sensitivity, the lookup key undergoes ASCII punyencoding and potential lowercasing before querying the underlying datastores. Sources: [apps/web/lib/middleware/link.ts:43-63](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L63), [apps/web/lib/planetscale/get-link-with-partner.ts:24-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts#L24-L61)
### Link Resolution Call-Chain Walkthrough
The runtime resolution of a short link and its associated partner data follows a precise execution sequence across the middleware and PlanetScale query layer:
1. `LinkMiddleware()` extracts the incoming request domain, full key, and search parameters using the `parse()` helper function. Sources: [apps/web/lib/middleware/link.ts:43-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L44)
2. The original key is encoded to ASCII via `punyEncode()`, and if the domain is not case-sensitive, it is normalized to lowercase. Sources: [apps/web/lib/middleware/link.ts:50-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L50-L56)
3. If inspect mode is triggered by a trailing `+` character, the suffix is stripped from the key before further processing. Sources: [apps/web/lib/middleware/link.ts:58-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L58-L62)
4. `getLinkWithPartner()` checks domain case sensitivity to apply either `encodeKey()` or a combination of `safeDecodeURIComponent()` and `punyEncode()` to construct `keyToQuery`. Sources: [apps/web/lib/planetscale/get-link-with-partner.ts:24-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts#L24-L34)
5. `conn.execute()` runs a relational query joining `Link` with `ProgramEnrollment`, `Partner`, `LinkReward`, `Discount`, and `Program` tables using `domain` and `keyToQuery`. Sources: [apps/web/lib/planetscale/get-link-with-partner.ts:37-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts#L37-L60)
```typescript
export const getLinkWithPartner = async ({
domain,
key,
}: {
domain: string;
key: string;
}): Promise => {
const keyToQuery = isCaseSensitiveDomain(domain)
? encodeKey(key)
: punyEncode(safeDecodeURIComponent(key));
console.time("getLinkWithPartner");
const { rows } =
(await conn.execute(
`SELECT
Link.*,
Partner.id as partnerId,
Partner.name as partnerName,
Partner.image as partnerImage,
ProgramEnrollment.groupId as groupId,
ProgramEnrollment.tenantId as tenantId,
PartnerDiscount.id as discountId,
PartnerDiscount.amount as discountAmount,
PartnerDiscount.type as discountType,
PartnerDiscount.maxDuration as discountMaxDuration,
PartnerDiscount.couponId as discountCouponId,
PartnerDiscount.couponTestId as discountCouponTestId
FROM Link
LEFT JOIN ProgramEnrollment ON ProgramEnrollment.programId = Link.programId AND ProgramEnrollment.partnerId = Link.partnerId
LEFT JOIN Partner ON Partner.id = ProgramEnrollment.partnerId
LEFT JOIN LinkReward ON LinkReward.linkId = Link.id
LEFT JOIN Discount PartnerDiscount ON PartnerDiscount.id = COALESCE(LinkReward.discountId, ProgramEnrollment.discountId)
AND PartnerDiscount.programId IS NOT NULL
LEFT JOIN Program ON Program.id = Link.programId
WHERE Link.domain = ? AND Link.key = ?`,
[domain, keyToQuery],
)) || {};
console.timeEnd("getLinkWithPartner");
const link =
rows && Array.isArray(rows) && rows.length > 0 ? (rows[0] as any) : null;
if (!link) {
return null;
}
// ... maps results and returns EdgeLinkProps
};
```
Sources: [apps/web/lib/planetscale/get-link-with-partner.ts:24-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts#L24-L70)
### Link Database Schema Indexing Strategy
The `Link` model defines dedicated unique constraints and indexes to guarantee sub-millisecond retrieval performance across various lookup vectors at the edge.
| Composite Key / Index | Fields Covered | Purpose / Target Operation |
| :--- | :--- | :--- |
| Unique Constraint | `[domain, key]` | Direct short link retrieval by domain and path key. Sources: [apps/web/prisma/schema/link.prisma:96|96](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L96-L96) |
| Unique Constraint | `[projectId, externalId]` | API usage and multi-tenant lookups via external identifiers. Sources: [apps/web/prisma/schema/link.prisma:97|97](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L97-L97) |
| Index | `[projectId, tenantId]` | Tenant-scoped filtering for workspace resource queries. Sources: [apps/web/prisma/schema/link.prisma:98|98](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L98-L98) |
| Index | `[projectId, url(length: 500)]` | High-performance URL upserts and duplicate detection. Sources: [apps/web/prisma/schema/link.prisma:99|99](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L99-L99) |
| Index | `[programId, partnerId]` | Referral link lookups combining program and partner associations. Sources: [apps/web/prisma/schema/link.prisma:101|101](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L101-L101) |
| Index | `[domain, createdAt]` | Bulk link deletion workflows and cleanup of short-lived links. Sources: [apps/web/prisma/schema/link.prisma:103|103](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L103-L103) |
Sources: [apps/web/prisma/schema/link.prisma:96-107](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L96-L107)
> [!NOTE]
> Link expiration timestamps (`expiresAt`) are synchronized with Redis Time-To-Live (TTL) values, whereas target URLs (`url`), proxy configurations (`proxy`), domains (`domain`), and link keys (`key`) are mirrored directly on Redis nodes for edge acceleration alongside primary MySQL storage. Sources: [apps/web/prisma/schema/link.prisma:3-8|13|13](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/link.prisma#L3-L8)
## Partner Programs and Link Associations
### Overview
The partner program architecture in Dub relies on a multi-model relational schema connecting workspaces, programs, partner enrollments, partner groups, and partner-specific link creation endpoints. A partner program (`Program`) acts as the top-level container defined within a workspace (`Project`), establishing primary reward settings, domains, and payout modes. Partners (`Partner`) enroll in programs through `ProgramEnrollment` records, which track performance metrics, reward overrides, and status lifecycle values. Partners can also be organized into `PartnerGroup` entities, defining group-level link structures, default reward assignments, and UTM template configurations.
Sources: [apps/web/prisma/schema/program.prisma:27-99](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L27-L99), [apps/web/prisma/schema/group.prisma:7-54](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/group.prisma#L7-L54)
### Partner Enrollment Statuses and Lifecycle
Program enrollments govern the state of a partner inside a specific program using the `ProgramEnrollmentStatus` enum. Each status handles a distinct phase of partner onboarding, review, or deactivation.
| Status Name | Description / Meaning |
| :--- | :--- |
| `pending` | Pending applications that need administrative approval. Sources: [apps/web/prisma/schema/program.prisma:2|2](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L2-L2) |
| `approved` | Partner who has been approved or actively enrolled. Sources: [apps/web/prisma/schema/program.prisma:3|3](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L3-L3) |
| `rejected` | Program rejected the partner application. Sources: [apps/web/prisma/schema/program.prisma:4|4](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L4-L4) |
| `invited` | Partner who has received an invitation to join. Sources: [apps/web/prisma/schema/program.prisma:5|5](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L5-L5) |
| `declined` | Partner declined the program invitation. Sources: [apps/web/prisma/schema/program.prisma:6|6](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L6-L6) |
| `deactivated` | Partner is manually deactivated by the program. Sources: [apps/web/prisma/schema/program.prisma:7|7](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L7-L7) |
| `banned` | Partner is banned from participating in the program. Sources: [apps/web/prisma/schema/program.prisma:8|8](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L8-L8) |
| `archived` | Partner record is archived by the program. Sources: [apps/web/prisma/schema/program.prisma:9|9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L9-L9) |
Sources: [apps/web/prisma/schema/program.prisma:1-10](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L1-L10)
> [!IMPORTANT]
> The `ProgramEnrollment` model enforces unique constraints on both `[partnerId, programId]` and `[tenantId, programId]`, ensuring that a single partner or tenant cannot duplicate active enrollments within the same program boundary. Sources: [apps/web/prisma/schema/program.prisma:174-177](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/program.prisma#L174-L177)
### Partner Link Creation Call-Chain Walkthrough
Partner-specific links are created via the POST endpoint at `apps/web/app/ee/api/partners/links/route.ts`. The request execution follows an explicit validation and processing sequence before persisting the link:
1. `createPartnerLinkSchemaInternal.parse()` validates the incoming JSON request body for partner identifiers, custom URLs, keys, and reward IDs. Sources: [apps/web/app/ee/api/partners/links/route.ts:98-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L98-L108)
2. `getProgramOrThrow()` fetches the target program and verifies that its domain and base URL are configured. Sources: [apps/web/app/ee/api/partners/links/route.ts:110-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L110-L121)
3. `prisma.programEnrollment.findUnique()` queries the database using either `partnerId` or `tenantId` combined with `programId`, loading partner relation fields and associated `partnerGroup` defaults and UTM templates. Sources: [apps/web/app/ee/api/partners/links/route.ts:125-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L125-L144)
4. `processLink()` executes core link validation and generation logic, registering the target URL, domain, program ID, tenant ID, and partner ID. Sources: [apps/web/app/ee/api/partners/links/route.ts:165-180](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L165-L180)
5. `applyGroupUtmToLink()` applies group UTM template parameters and partner naming conventions to the generated link object. Sources: [apps/web/app/ee/api/partners/links/route.ts:189-193](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L189-L193)
6. `throwIfInvalidRewards()` validates link-level reward assignments against the program and partner group constraints. Sources: [apps/web/app/ee/api/partners/links/route.ts:216-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L216-L220)
7. `createLink()` persists the final constructed link record with optional reward overrides. Sources: [apps/web/app/ee/api/partners/links/route.ts:222-225](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L222-L225)
Sources: [apps/web/app/ee/api/partners/links/route.ts:94-225](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L94-L225)
> [!WARNING]
> If a partner attempts to create a link with custom link-level rewards (`clickRewardId`, `leadRewardId`, `saleRewardId`, or `discountId`), the workspace plan capability check (`canUseAdvancedRewardLogic`) is evaluated. If the workspace plan does not permit advanced reward logic, the operation throws a forbidden `DubApiError` with the `PARTNER_LEVEL_REWARDS_PLAN_ERROR` constant. Sources: [apps/web/app/ee/api/partners/links/route.ts:197-214](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L197-L214)
## Commissions, Rewards, and Aggregations
### Overview
The reward and commission data architecture manages partner compensation, event tracking, and periodic click aggregation. Rewards define payout structures and constraints using fixed or percentage models across event types, while commissions record individual financial ledger entries tied to clicks, leads, sales, or referrals.
Sources: [apps/web/prisma/schema/commission.prisma:37-62](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma#L37-L62), [apps/web/prisma/schema/reward.prisma:21-38](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/reward.prisma#L21-L38)
### Reward and Commission Enumerations
The Prisma schema defines specific enumeration sets governing event classification, payout structures, commission statuses, and data origins.
| Enum Type | Real Values | Description |
| :--- | :--- | :--- |
| `EventType` / `CommissionType` | `click`, `lead`, `sale`, `referral`, `custom` | Categorizes the tracked interaction triggering rewards or commissions. |
| `RewardStructure` | `percentage`, `flat` | Defines whether compensation is calculated as a percentage rate or a flat cash amount. |
| `RewardSpendLimitInterval` | `allTime`, `day`, `week`, `month` | Defines the time window for bounding reward spend caps. |
| `CommissionStatus` | `pending`, `processed`, `paid`, `refunded`, `duplicate`, `fraud`, `canceled`, `hold` | Represents the lifecycle state of a commission entry. |
| `CommissionSource` | `api`, `user`, `stripe`, `shopify`, `hubspot`, `appsflyer`, `singular`, `rewardful`, `partnerstack`, `firstpromoter`, `tolt`, `tapfiliate`, `lemonsqueezy`, `affiliatewp` | Identifies the originating integration platform or creation context for a commission. |
Sources: [apps/web/prisma/schema/commission.prisma:1-35](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma#L1-L35), [apps/web/prisma/schema/reward.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/reward.prisma#L1-L20)
### Click Aggregation Pipeline Call-Chain Walkthrough
The periodic click aggregation pipeline processes link engagement data and batches click commissions. The background execution follows an explicit function call sequence:
1. `processClickAggregation()` queries `prisma.programEnrollment.findUnique()` using partner and program IDs, checking whether the enrollment status is commission-eligible. Sources: [apps/web/lib/commissions/process-click-aggregation.ts:39-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L39-L70)
2. `calculateEarningsByLink()` queries active links with clicks after the start date, fetches geographic click distributions via `getTopLinksByCountries()`, and calculates link-level earnings against configured click rewards. Sources: [apps/web/lib/commissions/process-click-aggregation.ts:90-148](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L90-L148)
3. `createClickCommissions()` iterates over link earnings, evaluating reward spend limits via `getHistoricalClicksEarnings()` and building ledger entries with idempotency keys. Sources: [apps/web/lib/commissions/process-click-aggregation.ts:254-349](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L254-L349)
4. `prisma.commission.createMany()` persists the batched click commissions with `skipDuplicates: true`. Sources: [apps/web/lib/commissions/process-click-aggregation.ts:359-362](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L359-L362)
5. `syncTotalCommissions()` updates aggregate partner program totals following successful insertion. Sources: [apps/web/lib/commissions/process-click-aggregation.ts:369-373](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L369-L373)
Sources: [apps/web/lib/commissions/process-click-aggregation.ts:33-374](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L33-L374)
> [!NOTE]
> Click commissions rely on `invoiceId` formatted as `${linkId}-${aggregationDate}` combined with a unique constraint on `[invoiceId, programId]` to ensure idempotency during batch aggregation runs. Sources: [apps/web/prisma/schema/commission.prisma:73|73](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/commission.prisma#L73-L73), [apps/web/lib/commissions/process-click-aggregation.ts:348|348](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/commissions/process-click-aggregation.ts#L348-L348)
### Commission Retrieval and Filtering API
The `getCommissions()` function queries commission records for admin dashboards and partner portals, applying dynamic status filters and metadata predicates.
```typescript
export async function getCommissions(filters: CommissionsFilters) {
const { invoiceId, programId, partnerId, status, type, ... } = filters;
const parsedMetadataQuery = parseCommissionMetadataQuery(query);
const metadataWhere = buildCommissionMetadataWhere(parsedMetadataQuery);
if (invoiceId) {
return await prisma.commission.findMany({
where: { invoiceId, programId, ...metadataWhere },
include: { ...commissionIncludes },
});
}
const paginationQuery = buildPaginationQuery(filters);
const statusFilter = status
? status
: type || customerId || payoutId || bountySubmissionId || partnerId
? undefined
: {
notIn: [
CommissionStatus.duplicate,
CommissionStatus.fraud,
CommissionStatus.canceled,
],
};
return await prisma.commission.findMany({
where: {
earnings: { not: 0 },
programId,
status: statusFilter,
createdAt: { gte: startDate, lte: endDate },
...metadataWhere,
},
include: { ...commissionIncludes },
...paginationQuery,
});
}
```
Sources: [apps/web/lib/api/commissions/get-commissions.ts:35-209](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts#L35-L209)
> [!WARNING]
> When querying commissions without an explicit status filter, duplicate, fraud, and canceled commissions are automatically excluded from the result set unless specifically requested via filters. Sources: [apps/web/lib/api/commissions/get-commissions.ts:141-151](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/get-commissions.ts#L141-L151)
## Batch Migrations and Performance Optimizations
### Overview
Bulk data migrations and performance tuning require careful handling of batch sizes, transaction boundaries, and pagination constants when interacting with PlanetScale and Prisma. Large-scale data transformations, such as backfilling metadata or correcting partner link alignments, use bounded iteration loops and explicit limits to prevent memory exhaustion and database lock contention. Sources: [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts:101-125](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts#L101-L125), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:25-30](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L25-L30)
### Metadata Backfill Execution Workflow
The commission metadata backfilling script processes records in batched loops, pulling source event metadata from Tinybird and performing bulk updates via raw SQL execution. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:115-246](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L115-L246)
1. `main()` initiates an infinite iteration loop governed by a fixed `BATCH_SIZE` of `1000` records and a throttle delay of `1000` ms. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:26-27](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L26-L27), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:115-116](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L115-L116)
2. `prisma.commission.findMany()` queries target commissions where `type` is lead or sale, `eventId` is not null, `metadata` is null, and `id` is greater than the current cursor (`startingAfter`), ordered ascending by `id`. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:116-142](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L116-L142)
3. `getEventsMetadata()` executes a Tinybird pipe query using extracted event IDs to retrieve corresponding event metadata payloads. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:150-159](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L150-L159)
4. `parseUserProvidedMetadata()` validates raw string payloads against length bounds (`USER_METADATA_MAX_CHARS` = `10,000`), JSON syntax, and internal event fingerprint checks (`looksLikeInternalEventPayload()`) to filter out webhook dumps. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:28-29](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L28-L29), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:50-98](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L50-L98)
5. `prisma.$executeRaw` applies conditional case statements (`CASE id WHEN ... THEN ... END`) to update commission records in bulk when `DRY_RUN` is disabled. Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:25](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L25), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:212-226](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L212-L226)
Sources: [apps/web/scripts/migrations/backfill-commissions-metadata.ts:100-251](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L100-L251)
> [!WARNING]
> When executing bulk `updateMany` operations on tables with large volumes of pending or un-processed records, always paginate or apply a `limit` parameter (such as `PRISMA_UPDATEMANY_LIMIT`) inside a `while (true)` loop to prevent exceeding lock timeouts or memory thresholds on PlanetScale. Sources: [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts:101-125](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts#L101-L125)
### Migration Script Constants and Parameters
| Constant Name | Value | Purpose | Sources |
| --- | --- | --- | --- |
| `DRY_RUN` | `true` | Safeguard flag for backfill scripts to log proposed updates without executing database writes. | [apps/web/scripts/migrations/backfill-commissions-metadata.ts:25](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L25) |
| `BATCH_SIZE` | `1000` | Number of records fetched and processed per iteration cycle in backfill and rewind scripts. | [apps/web/scripts/dub-partner-rewind.ts:45](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts#L45), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:26](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L26) |
| `THROTTLE_MS` | `1000` | Millisecond sleep duration between migration batches to prevent resource exhaustion. | [apps/web/scripts/migrations/backfill-commissions-metadata.ts:27](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L27) |
| `USER_METADATA_MAX_CHARS` | `10000` | Maximum character length allowed for user-provided metadata strings, matching core Zod schemas. | [apps/web/scripts/migrations/backfill-commissions-metadata.ts:29](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L29) |
| `REWIND_EARNINGS_MINIMUM` | `100` (1 USD) | Minimum total commission earnings threshold in cents required to include a partner in rewind summaries. | [apps/web/scripts/dub-partner-rewind.ts:6](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts#L6) |
Sources: [apps/web/scripts/dub-partner-rewind.ts:6-45](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts#L6-L45), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:25-29](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L25-L29)
### Design Trade-offs in Bulk Operations
| Design Choice | Benefit | Cost | Sources |
| --- | --- | --- | --- |
| Chunked transactions (`chunk(payloads, 1000)`) | Avoids query size limits and memory spikes during bulk table rewinds. | Requires multiple sequential database round-trips inside transaction blocks. | [apps/web/scripts/dub-partner-rewind.ts:45-61](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts#L45-L61) |
| Raw SQL `CASE id WHEN` updates | Enables high-performance batch updates of distinct scalar values across non-uniform rows in a single query. | Bypasses Prisma client-level type safety and middleware hooks for that operation. | [apps/web/scripts/migrations/backfill-commissions-metadata.ts:212-226](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L212-L226) |
| Cursor-based pagination (`id > startingAfter`) | Provides stable, memory-efficient traversal over large sorted datasets without offset degradation. | Requires ordered primary or unique keys and complicates random-access navigation. | [apps/web/scripts/migrations/backfill-commissions-metadata.ts:127-142](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L127-L142) |
Sources: [apps/web/scripts/dub-partner-rewind.ts:45-61](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-partner-rewind.ts#L45-L61), [apps/web/scripts/migrations/backfill-commissions-metadata.ts:127-226](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-commissions-metadata.ts#L127-L226)
## Related
- [[Caching and Edge Config]]
- [[Link Creation and Builder UI]]
- [[Commission Rules and Rewards]]
---
## Technical docs: POST Renew a registered domain
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/renewadmindomain
## Request Body
Domain to renew
## Responses
## Try It
---
## Technical docs: Caching and Edge Config
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/caching-and-edge-config
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/api/links/cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts)
- [apps/web/lib/upstash/redis.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/app/api/domains/domain/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts)
- [apps/web/app/api/domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts)
- [apps/web/lib/upstash/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/index.ts)
- [apps/web/lib/planetscale/get-link-via-edge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts)
- [apps/web/app/ee/api/cron/domains/update/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts)
- [apps/web/lib/api/links/record-click-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts)
- [apps/web/app/api/workspaces/idOrSlug/import/short/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/short/route.ts)
- [apps/web/lib/planetscale/get-domain-via-edge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-domain-via-edge.ts)
- [apps/web/lib/analytics/allowed-hostnames-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/allowed-hostnames-cache.ts)
- [apps/web/app/ee/api/cron/sync-redis-resources/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts)
- [apps/web/lib/fetchers/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/fetchers/index.ts)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts)
- [apps/web/app/ee/api/cron/streams/update-click-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts)
- [apps/web/lib/middleware/utils/cache-deeplink-click-data.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/cache-deeplink-click-data.ts)
- [apps/web/lib/api/workspaces/workspace-product-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/workspace-product-cache.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/domains/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/domains/page-client.tsx)
- [apps/web/lib/planetscale/get-shortlink-via-edge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-shortlink-via-edge.ts)
- [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts)
- [apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts)
- [apps/web/lib/upstash/ratelimit-policies.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts)
- [apps/web/app/api/domains/client/saved/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/client/saved/route.ts)
- [apps/web/app/ee/api/cron/links/invalidate-for-partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/links/invalidate-for-partners/route.ts)
- [apps/web/lib/auth/token-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts)
- [apps/web/app/wellknown/domain/file/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/wellknown/%5Bdomain%5D/%5Bfile%5D/route.ts)
- [apps/web/lib/api/rewards/reward-version.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/reward-version.ts)
- [apps/web/lib/upstash/record-metatags.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/record-metatags.ts)
- [apps/web/app/api/domains/default/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/default/route.ts)
## Overview
Dub relies on a multi-tiered caching and edge configuration architecture to minimize database load, maintain high availability during traffic spikes, and ensure ultra-low latency link resolution at the edge. By combining In-Memory LRU caches, Vercel runtime cache layers, and global Upstash Redis instances, the system optimizes read operations and handles failovers seamlessly when remote stores encounter disruptions.
Sources: [apps/web/lib/api/links/cache.ts:15-35](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L15-L35), [apps/web/lib/upstash/redis.ts:9-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L9-L15)
Beyond short link metadata, this infrastructure orchestrates domain lifecycle synchronization, edge-level rate limiting policies, background cache invalidation cron workers, and specialized auxiliary caches for authentication tokens, workspace flags, hostnames, and metatags.
Sources: [apps/web/lib/api/links/cache.ts:44-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L44-L48), [apps/web/app/ee/api/cron/domains/update/route.ts:98-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts#L98-L106), [apps/web/lib/upstash/ratelimit-policies.ts:18-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L18-L24), [apps/web/lib/auth/token-cache.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L4-L7)
## Redis Client and Global Infrastructure
### Overview
The Upstash Redis infrastructure establishes multiple client instances to isolate critical background operations from standard application traffic. Publicly exported connection handlers initialize clients using environment variables for REST endpoints and authentication tokens, supporting both standard database connections and dedicated global infrastructure layers.
Sources: [apps/web/lib/upstash/redis.ts:1-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L1-L7), [apps/web/lib/upstash/redis.ts:9-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L9-L26)
### Global Connection Configuration
Client initialization checks for specialized global environment variables to determine whether operations should target a secondary Upstash Redis cluster. If `UPSTASH_GLOBAL_REDIS_REST_URL` and `UPSTASH_GLOBAL_REDIS_REST_TOKEN` are both present, `redisConfig` routes connections to the global cluster; otherwise, it falls back to the standard `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.
Sources: [apps/web/lib/upstash/redis.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L12-L23)
Three primary Redis client instances are exported for application usage:
* `redis`: Connects using standard primary environment variables (`UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`).
* `redisGlobal`: Connects using `redisConfig`, targeting global infrastructure (such as `linkCache` and `recordClick`) so that transient cluster failures do not impact unrelated endpoints.
* `redisGlobalWithTimeout`: Extends `redisConfig` by injecting a signal method enforcing an `AbortSignal.timeout(1500)` constraint.
Sources: [apps/web/lib/upstash/redis.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L4-L7), [apps/web/lib/upstash/redis.ts:9-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L9-L15), [apps/web/lib/upstash/redis.ts:25-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L25-L30)
> [!NOTE]
> The `redisGlobalWithTimeout` client enforces a strict 1500ms timeout via `AbortSignal.timeout(1500)`, making it suitable for latency-sensitive read paths like `RecordClickCache.get()`.
> Sources: [apps/web/lib/upstash/redis.ts:27-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L27-L30), [apps/web/lib/api/links/record-click-cache.ts:28-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L28-L32)
### Click Caching Operations
#### Overview
The `RecordClickCache` class abstracts click deduplication and persistence through the global Redis clients. Click keys are formulated using domain, link key, and identity hash attributes.
Sources: [apps/web/lib/api/links/record-click-cache.ts:6-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L6-L11), [apps/web/lib/api/links/record-click-cache.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L34-L36)
#### Call-Chain Execution Walkthrough
When checking or recording a click ID, operations flow through specific methods in the cache layer:
1. `RecordClickCache._createKey()` formats the namespaced Redis key string: `recordClick:${domain}:${key}:${identityHash}`.
Sources: [apps/web/lib/api/links/record-click-cache.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L34-L36)
2. `RecordClickCache.set()` invokes `redisGlobal.set()`, passing the generated key, `clickId` value, and an expiration option `ex` set to `CACHE_EXPIRATION` (60 seconds Ă— 60 minutes = 3600 seconds).
Sources: [apps/web/lib/api/links/record-click-cache.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L4-L5), [apps/web/lib/api/links/record-click-cache.ts:13-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L13-L26)
3. `RecordClickCache.get()` invokes `redisGlobalWithTimeout.get()`, querying the store with the 1500ms abort signal boundary enforced.
Sources: [apps/web/lib/api/links/record-click-cache.ts:28-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L28-L32)
### Redis Infrastructure Reference
| Exported Identifier | Underlying Configuration Source | Timeout Signal | Primary Purpose |
| :--- | :--- | :--- | :--- |
| `redis` | `UPSTASH_REDIS_REST_URL` / `TOKEN` | None | Standard application Redis operations |
| `redisGlobal` | `UPSTASH_GLOBAL_REDIS_REST_URL` (with fallback) | None | Global operations (`linkCache`, `recordClick`) |
| `redisGlobalWithTimeout` | `UPSTASH_GLOBAL_REDIS_REST_URL` (with fallback) | `AbortSignal.timeout(1500)` | Time-bounded global read queries (`RecordClickCache.get`) |
Sources: [apps/web/lib/upstash/redis.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L4-L7), [apps/web/lib/upstash/redis.ts:12-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/redis.ts#L12-L30), [apps/web/lib/api/links/record-click-cache.ts:28-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L28-L32)
## Link Caching Layer Architecture
### Overview
The `LinkCache` class and its supporting runtime structures manage short link metadata resolution, multi-tier caching, and fallback execution. To mitigate database load during traffic spikes and regional cold starts, short link lookups combine an in-memory `LRUCache` instance (bounded at 10,000 entries with a 5-second TTL), global Upstash Redis storage (`redisGlobal` and `redisGlobalWithTimeout`), Vercel runtime cache (`vercelCache`), and direct PlanetScale database queries via edge connectors (`getLinkViaEdge`).
Sources: [apps/web/lib/api/links/cache.ts:8-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L8-L27), [apps/web/lib/api/links/cache.ts:71-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L71-L136)
### Read Operations and Fallback Mechanics
#### Overview
When a link lookup is requested via `LinkCache.get({ domain, key })`, the execution traverses multiple cache tiers and fallback mechanisms before returning link metadata or throwing a 404 error.
Sources: [apps/web/lib/api/links/cache.ts:71-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L71-L136)
#### Call-Chain Execution Walkthrough
1. `LinkCache.get()` constructs the namespace-aware key `linkcache:${domain}:${key}`.
Sources: [apps/web/lib/api/links/cache.ts:78-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L78-L78)
2. It probes the in-memory `linkLRUCache.get(cacheKey)`. If found, it refreshes the LRU entry and returns immediately with `redisFailOver: false`.
Sources: [apps/web/lib/api/links/cache.ts:81-87](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L81-L87)
3. On an LRU miss, it queries global Redis using `redisGlobalWithTimeout.get(cacheKey)`. If found, it populates the LRU cache and returns with `redisFailOver: false`.
Sources: [apps/web/lib/api/links/cache.ts:93-98](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L93-L98)
4. If Redis throws an error or times out, execution enters the catch block, querying `vercelCache.get(cacheKey)`. If present, it updates the LRU cache and returns with `redisFailOver: true`.
Sources: [apps/web/lib/api/links/cache.ts:105-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L105-L113)
5. On a Vercel cache miss, it calls `getLinkViaEdge({ domain, key })`. If no database record is found, it throws an explicit `Error("Link not found.")`. Otherwise, it formats the link via `formatRedisLink()`, writes it to the LRU cache, registers a background write to Vercel cache using `waitUntil()`, and returns with `redisFailOver: true`.
Sources: [apps/web/lib/api/links/cache.ts:117-134](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L117-L134)
> [!WARNING]
> Because LRU caches are unshared across newly spun-up Fluid runtime instances during traffic surges, `LinkCache` falls back to `vercelCache` whenever global Redis is unavailable, preventing stampedes on the underlying database.
> Sources: [apps/web/lib/api/links/cache.ts:23-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L23-L27), [apps/web/lib/api/links/cache.ts:105-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L105-L113)
### Write, Deletion, and Expiration Operations
`LinkCache` provides batch and single-item modification routines that synchronize Redis pipelines, LRU entries, and Next.js cache tags.
Sources: [apps/web/lib/api/links/cache.ts:33-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L33-L70), [apps/web/lib/api/links/cache.ts:138-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L138-L171)
| Method Signature | Operations Performed | Expiration / TTL | Sources |
| :--- | :--- | :--- | :--- |
| `LinkCache.set(link, options)` | Updates LRU cache, triggers `revalidateTag`, updates Redis, and invalidates Vercel runtime cache. | 24 Hours (`REDIS_CACHE_EXPIRATION`) | [apps/web/lib/api/links/cache.ts:50-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L50-L69) |
| `LinkCache.mset(links)` | Pipeline sets multiple links in global Redis and revalidates individual tags. | 24 Hours (`REDIS_CACHE_EXPIRATION`) | [apps/web/lib/api/links/cache.ts:33-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L33-L48) |
| `LinkCache.delete({ domain, key })` | Invalidates Vercel runtime cache via `waitUntil()` and deletes the key from `redisGlobal`. | Immediate deletion | [apps/web/lib/api/links/cache.ts:138-142](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L138-L142) |
| `LinkCache.deleteMany(links)` | Pipelines deletion of multiple link keys across `redisGlobal`. | Immediate deletion | [apps/web/lib/api/links/cache.ts:144-156](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L144-L156) |
| `LinkCache.expireMany(links)` | Pipelines setting key expirations to 1 second for rapid cache invalidation. | 1 Second | [apps/web/lib/api/links/cache.ts:158-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L158-L171) |
> [!TIP]
> The `_createKey()` helper checks domain case-sensitivity using `isCaseSensitiveDomain(domain)`, decoding case-sensitive keys or normalizing case-insensitive keys to lowercase to maintain consistent cache namespaces.
> Sources: [apps/web/lib/api/links/cache.ts:173-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L173-L179)
### Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Multi-tier caching (LRU + Redis + Vercel Cache) | Extremely low latency reads and high resilience against regional database or Redis outages. | Increased state synchronization complexity across memory, external stores, and edge runtimes. |
| Pipeline execution for bulk writes (`mset`, `deleteMany`, `expireMany`) | Minimizes network round-trips when modifying multiple cache entries simultaneously. | Requires array length validation checks before executing empty pipelines. |
| In-flight deduplication map in `getLinkViaEdge` | Prevents duplicate concurrent database queries for identical domain and key lookups. | Retains short-lived promise references in memory until resolution settles. |
Sources: [apps/web/lib/api/links/cache.ts:33-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L33-L48), [apps/web/lib/api/links/cache.ts:71-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L71-L136), [apps/web/lib/api/links/cache.ts:144-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/cache.ts#L144-L171), [apps/web/lib/planetscale/get-link-via-edge.ts:41-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L41-L68)
## Edge Middleware Resolution and Fallbacks
### Overview
The Edge Middleware resolves incoming link requests by evaluating domain case sensitivity, normalization rules, and cache presence before falling back to direct edge database execution. `LinkMiddleware` parses the request URL to extract the domain and original key, normalizes keys to lowercase for case-insensitive domains or punycode-encodes them, strips inspect mode suffixes (`+`), and assigns root domain links to `_root`.
Sources: [apps/web/lib/middleware/link.ts:43-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L67)
### Call-Chain Execution Walkthrough
When an incoming request hits the middleware, resolution proceeds through a strict sequence of lookups and fallbacks:
1. `LinkMiddleware` calls `linkCache.get({ domain, key })` to fetch cached metadata and verify `redisFailOver` status.
2. If `cachedLink` is absent, it executes `getLinkViaEdge({ domain, key })`.
3. `getLinkViaEdge` checks `inFlightLinkLookups` Map; if a pending promise exists for the lookup key (`domain:key`), it awaits that promise. Otherwise, it invokes `getLinkViaEdgeHelper`.
4. `getLinkViaEdgeHelper` prepares the query using `isCaseSensitiveDomain(domain)` to decide whether to encode via `encodeKey` or decode and punycode-encode via `punyEncode(safeDecodeURIComponent(key))`, then queries the database via `conn.execute`.
5. If no database record is found and the domain equals `buff.ly`, control falls back to `crawlBitly(req, ev)` which queries the Bitly API, creates a database link via Prisma, records the link, and issues a redirect.
Sources: [apps/web/lib/middleware/link.ts:89-117](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L117), [apps/web/lib/planetscale/get-link-via-edge.ts:10-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L68), [apps/web/lib/middleware/utils/crawl-bitly.ts:20-82](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L82)
### Click Metadata and Caching Helpers
When processing valid links, the middleware evaluates whether to cache click identifiers and record click metadata based on conversion tracking flags, partner status, and tracking URL signatures.
Sources: [apps/web/lib/middleware/link.ts:175-184](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L175-L184)
| Helper / Store | File Path | Key Operations & TTL | Sources |
| :--- | :--- | :--- | :--- |
| `RecordClickCache` | [apps/web/lib/api/links/record-click-cache.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L1-L39) | Caches click IDs in global Redis using keys structured as `recordClick:${domain}:${key}:${identityHash}` with a 1-hour expiration (`60 * 60`). | [apps/web/lib/api/links/record-click-cache.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/record-click-cache.ts#L1-L39) |
| `cacheDeepLinkClickData` | [apps/web/lib/middleware/utils/cache-deeplink-click-data.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/cache-deeplink-click-data.ts#L1-L39) | Stores deep link click payloads (`clickId` and link properties) in Redis keyed by `deepLinkClickCache:${ip}:${link.domain}:${link.key}` with a 1-hour TTL. | [apps/web/lib/middleware/utils/cache-deeplink-click-data.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/cache-deeplink-click-data.ts#L1-L39) |
> [!WARNING]
> During Redis failover states, click tracking routines are explicitly skipped to prevent request timeouts, and cookie lookup or minting for `dubIdCookieName` is bypassed entirely.
> Sources: [apps/web/lib/middleware/link.ts:93-98](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L93-L98), [apps/web/lib/middleware/link.ts:193-211](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L193-L211)
## Domain Configuration and Vercel Synchronization
### Overview
Domain configurations manage routing, branding, and asset settings across workspaces while synchronizing records between primary database stores, Vercel deployments, and edge memory caches. When a domain is created or modified, API route handlers validate constraints, apply plan tier checks, and push configuration changes to external services before updating persistent storage.
Sources: [apps/web/app/api/domains/route.ts:96-173](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L96-L173), [apps/web/app/api/domains/domain/route.ts:45-64](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L45-L64)
### Edge Domain Retrieval and Caching
To optimize edge execution performance without repeated round-trips to the primary database, domain lookups at the edge leverage an in-memory Least Recently Used (LRU) cache.
```typescript
const domainLRUCache = new LRUCache({
max: 1000,
ttl: 5 * 60 * 1000, // 5 minutes
});
export const getDomainViaEdge = async (domain: string) => {
const cached = domainLRUCache.get(domain);
if (cached) {
return cached;
}
const { rows } =
(await conn.execute(
"SELECT * FROM Domain WHERE slug = ?",
[domain],
)) || {};
const result =
rows && Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
if (result !== null) {
domainLRUCache.set(domain, result);
}
return result;
};
```
Sources: [apps/web/lib/planetscale/get-domain-via-edge.ts:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-domain-via-edge.ts#L1-L28)
### Vercel Synchronization and Import Lifecycles
Domain registration invokes environment-specific synchronization with Vercel and manages bulk link transfers during workspace imports.
When a domain is added via the API, the system checks the `VERCEL` environment variable and registers the domain against Vercel infrastructure, optionally ignoring `domain_already_in_use` responses. During short.io imports, unmanaged source domains are automatically provisioned in the workspace database, synced with Vercel, and initialized with root records in a transaction block.
Sources: [apps/web/app/api/domains/route.ts:163-173](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L163-L173), [apps/web/app/api/workspaces/idOrSlug/import/short/route.ts:92-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/short/route.ts#L92-L125)
| Domain API Endpoint | HTTP Method | Action & Sync Behavior | Sources |
| :--- | :--- | :--- | :--- |
| `/api/domains` | `GET` | Retrieves all workspace domains with optional search filters, pagination, and root link mappings. | [apps/web/app/api/domains/route.ts:23-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L23-L94) |
| `/api/domains` | `POST` | Validates limits, parses asset JSON configurations, registers the domain with Vercel if `VERCEL === "1"`, and inserts the domain record. | [apps/web/app/api/domains/route.ts:97-173](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L97-L173) |
| `/api/domains/[domain]` | `GET` | Fetches a single workspace domain after executing validation and Dub domain checks. | [apps/web/app/api/domains/domain/route.ts:28-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L28-L42) |
| `/api/workspaces/[idOrSlug]/import/short` | `POST` | Discovers external Short.io domains, provisions missing items in Prisma, syncs them to Vercel, and queues cron import jobs. | [apps/web/app/api/workspaces/idOrSlug/import/short/route.ts:79-147](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/short/route.ts#L79-L147) |
> [!WARNING]
> When adding domains programmatically, failure to handle Vercel synchronization errors other than `domain_already_in_use` will cause the creation request to abort with an HTTP 422 status response.
> Sources: [apps/web/app/api/domains/route.ts:167-172](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L167-L172)
## Edge Rate Limiting Architecture
### Overview
The rate limiting infrastructure uses Upstash Redis to enforce endpoint-specific consumption policies across authentication, upload, file transfer, and domain management workflows. Policies are typed using `RatelimitPolicy` definitions and referenced by name across application routes.
Sources: [apps/web/lib/upstash/ratelimit-policies.ts:1-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L1-L16), [apps/web/lib/upstash/index.ts:1-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/index.ts#L1-L5)
### Rate Limit Policy Definitions
The `RATELIMIT_POLICIES` constant maps policy identifiers to specific attempt thresholds, time windows, key prefixes, and custom violation error messages.
| Policy Identifier | Attempts | Window | Key Prefix | Custom Message / Behavior | Sources |
| :--- | :--- | :--- | :--- | :--- | :--- |
| `login` | 5 | `1 m` | `rl:auth:login` | `too-many-login-attempts` (matched by sign-in page) | [apps/web/lib/upstash/ratelimit-policies.ts:19-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L19-L24) |
| `loginLinkSend` | 2 | `1 m` | `rl:auth:login-link:send` | — | [apps/web/lib/upstash/ratelimit-policies.ts:26-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L26-L30) |
| `emailChangeRequest` | 3 | `24 h` | `rl:auth:email-change` | — | [apps/web/lib/upstash/ratelimit-policies.ts:56-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L56-L60) |
| `emailChangeRequestTarget` | 3 | `24 h` | `rl:auth:email-change:target` | Keyed on target email address | [apps/web/lib/upstash/ratelimit-policies.ts:63-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L63-L67) |
| `workspaceFileUpload` | 20 | `1 h` | `rl:workspace:file:upload` | `Too many file uploads. Please try again later.` (Keyed on workspace + user) | [apps/web/lib/upstash/ratelimit-policies.ts:98-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L98-L104) |
| `reattributeCustomer` | 1 | `1 m` | `rl:customers:reattribute` | Dynamic function incorporating `retryAfter` context | [apps/web/lib/upstash/ratelimit-policies.ts:144-151](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L144-L151) |
| `anonymousLinkCreate` | 10 | `1 d` | `rl:links:create:anonymous` | `Rate limited – you can only create up to 10 links per day without an account.` | [apps/web/lib/upstash/ratelimit-policies.ts:298-305](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L298-L305) |
| `domainSearchAvailability` | 1 | `5 s` | `rl:domains:search-availability` | `Don't DDoS me pls 🥺` | [apps/web/lib/upstash/ratelimit-policies.ts:339-344](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L339-L344) |
Sources: [apps/web/lib/upstash/ratelimit-policies.ts:19-352](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L19-L352)
> [!NOTE]
> Policies such as `socialAccountVerification` are explicitly shared between start and verification routes so that both actions count toward a single unified platform budget.
> Sources: [apps/web/lib/upstash/ratelimit-policies.ts:173-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/ratelimit-policies.ts#L173-L179)
## Cache Invalidation and Background Sync
### Overview
Cache invalidation and background synchronization are managed through dedicated cron endpoints and background job processors that purge stale cache keys, synchronize Redis state resources, and process high-volume event streams. Operations such as domain updates, partner data modifications, and discount adjustments rely on batched routines to expire associated short links and maintain consistency between persistent storage and edge caches.
Sources: [apps/web/app/ee/api/cron/domains/update/route.ts:1-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts#L1-L121), [apps/web/app/ee/api/cron/sync-redis-resources/route.ts:1-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts#L1-L42), [apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts:1-210](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts#L1-L210)
### Cron and Background Sync Handlers
Background maintenance tasks execute on regular intervals, utilizing QStash signature verification and concurrency protection locks. The sync scheduler orchestrates Redis resource synchronization across workspace integrations and webhook sets.
Sources: [apps/web/app/ee/api/cron/sync-redis-resources/route.ts:1-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts#L1-L42), [apps/web/app/ee/api/cron/streams/update-click-stats/route.ts:1-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts#L1-L21)
| Handler Route / Module | Schedule / Mechanism | Action Performed | Sources |
| :--- | :--- | :--- | :--- |
| `/api/cron/sync-redis-resources` | Every 5 minutes (`*/5 * * * *`) | Rebuilds Redis sets for click webhook workspaces, cleans redundant link webhooks, and syncs Google Ads installed workspaces. | [apps/web/app/ee/api/cron/sync-redis-resources/route.ts:16-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts#L16-L23) |
| `/api/cron/cleanup/link-retention` | Once every 12 hours (`0 */12 * * *`) | Deletes expired links exceeding domain `linkRetentionDays` in batches of 100 with pagination. | [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:13-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L13-L39) |
| `/api/cron/links/invalidate-for-partners` | QStash triggered POST | Queries program enrollments and associated links for a given `partnerId` and expires them in `linkCache`. | [apps/web/app/ee/api/cron/links/invalidate-for-partners/route.ts:14-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/links/invalidate-for-partners/route.ts#L14-L49) |
| `invalidate-links-for-discounts-job` | Background job handler | Dispatches partner and discount scanning routines to clear Redis caches when resolved link discounts change. | [apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts:42-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts#L42-L53) |
Sources: [apps/web/app/ee/api/cron/sync-redis-resources/route.ts:1-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts#L1-L42), [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:1-124](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L1-L124), [apps/web/app/ee/api/cron/links/invalidate-for-partners/route.ts:1-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/links/invalidate-for-partners/route.ts#L1-L54), [apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts:1-210](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts#L1-L210)
### Domain Migration and Batch Invalidation Execution
When migrating links between domains via `/api/cron/domains/update`, processing occurs through a batched lifecycle workflow to prevent timeout and memory exhaustion.
```mermaid
sequenceDiagram
participant QStash as QStash Cron
participant Route as /api/cron/domains/update
participant DB as Prisma DB
participant Cache as linkCache
participant Queue as queueDomainUpdate
QStash->>Route: POST payload (oldDomain, newDomain, startingAfter)
Route->>DB: prisma.link.findMany({ where: { domain: oldDomain }, take: 100 })
DB-->>Route: linksToUpdate[]
Route->>DB: prisma.link.updateMany({ domain: newDomain })
Route->>DB: prisma.link.findMany (with tags & program enrollment)
Route->>Cache: linkCache.expireMany(linksToUpdate)
Route->>Queue: queueDomainUpdate({ startingAfter: lastLinkId, delay: 1 })
Queue-->>Route: Scheduled next batch messageId
Route-->>QStash: Log and respond with success
```
Sources: [apps/web/app/ee/api/cron/domains/update/route.ts:21-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts#L21-L117)
> [!WARNING]
> Background update routines such as `updateShortLinks` and cache expirations rely on `Promise.allSettled` or chunked iteration to ensure partial network failures in external services do not halt database cursor progression or leave pagination cursors stranded.
> Sources: [apps/web/app/ee/api/cron/domains/update/route.ts:98-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts#L98-L105), [apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts:204-206](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/invalidate-links-for-discounts-job.ts#L204-L206)
## Auxiliary Token and Metadata Caching
### Overview
Specialized caching mechanisms support auxiliary workflows across the platform, including restricted authentication tokens, workspace product flags, hostnames, metatags, reward versions, and onboarding states. These stores use Upstash Redis pipelines and key-value methods to maintain TTLs and minimize database pressure for frequently queried auxiliary entities.
Sources: [apps/web/lib/analytics/allowed-hostnames-cache.ts:1-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/allowed-hostnames-cache.ts#L1-L53), [apps/web/lib/api/workspaces/workspace-product-cache.ts:1-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/workspace-product-cache.ts#L1-L27), [apps/web/lib/auth/token-cache.ts:1-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L1-L76), [apps/web/lib/api/rewards/reward-version.ts:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/reward-version.ts#L1-L50), [apps/web/lib/upstash/record-metatags.ts:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/record-metatags.ts#L1-L20)
### Auxiliary Cache Configurations and TTLs
| Cache Service / Store | Key Prefix | TTL / Expiration | Source File |
| :--- | :--- | :--- | :--- |
| `AllowedHostnamesCache` | `allowedHostnamesCache` | 7 days (`60 * 60 * 24 * 7`) | [apps/web/lib/analytics/allowed-hostnames-cache.ts:3-4](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/allowed-hostnames-cache.ts#L3-L4) |
| `WorkspaceProductCache` | `workspace:product` | 30 days (`60 * 60 * 24 * 30`) | [apps/web/lib/api/workspaces/workspace-product-cache.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/workspace-product-cache.ts#L4-L5) |
| `TokenCache` | `dubTokenCache` | 24 hours (`60 * 60 * 24`) | [apps/web/lib/auth/token-cache.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L4-L5) |
| Reward Version | `reward-version:{groupId}:{event}` | 24 hours (`24 * 60 * 60`) | [apps/web/lib/api/rewards/reward-version.ts:4-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/reward-version.ts#L4-L14) |
| Onboarding Domain | `onboarding-domain:{workspaceId}` | 15 days (`60 * 60 * 24 * 15`) | [apps/web/app/api/domains/client/saved/route.ts:20-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/client/saved/route.ts#L20-L25) |
Sources: [apps/web/lib/analytics/allowed-hostnames-cache.ts:3-4](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/allowed-hostnames-cache.ts#L3-L4), [apps/web/lib/api/workspaces/workspace-product-cache.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/workspace-product-cache.ts#L4-L5), [apps/web/lib/auth/token-cache.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L4-L5), [apps/web/lib/api/rewards/reward-version.ts:4-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/reward-version.ts#L4-L14), [apps/web/app/api/domains/client/saved/route.ts:20-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/client/saved/route.ts#L20-L25)
### Token and Metatag Management Operations
Restricted and legacy personal tokens use `TokenCache` to serialize parsed token records against hashed keys, executing batch expirations by forcing TTL reductions down to 1 second via Redis pipelines. Metatag generation metrics and failures are tracked using Upstash sorted set increments via `recordMetatags`, routing errors to `metatags-error-zset` and successful domain generations to `metatags-zset`.
Sources: [apps/web/lib/auth/token-cache.ts:31-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L31-L69), [apps/web/lib/upstash/record-metatags.ts:8-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/record-metatags.ts#L8-L20)
> [!NOTE]
> When executing `AllowedHostnamesCache.mset` or `TokenCache.expireMany`, empty input arrays short-circuit execution without invoking redis pipeline instructions.
> Sources: [apps/web/lib/analytics/allowed-hostnames-cache.ts:14-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/allowed-hostnames-cache.ts#L14-L16), [apps/web/lib/auth/token-cache.ts:57-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L57-L60)
## Related
- [[Routing and Multitenancy]]
- [[Link Resolution and Redirection]]
---
## Technical docs: Background Jobs and Queues
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/background-jobs-and-queues
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/jobs/outbox.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts)
- [apps/web/lib/jobs/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts)
- [apps/web/app/api/jobs/process/jobName/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts)
- [apps/web/app/ee/api/cron/campaigns/broadcast/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts)
- [apps/web/app/ee/api/cron/campaigns/queue-scheduled/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts)
- [apps/web/lib/jobs/send-jobs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-jobs.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/lib/cron/enqueue-batch-jobs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/enqueue-batch-jobs.ts)
- [apps/web/app/ee/api/cron/program-application-reminder/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts)
- [apps/web/app/ee/api/cron/streams/update-click-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts)
- [apps/web/lib/api/rewards/queue-reward-processing.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/queue-reward-processing.ts)
- [apps/web/app/ee/api/cron/rewards/queue-custom-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts)
- [apps/web/app/ee/api/cron/queue/retry/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/queue/retry/route.ts)
- [apps/web/app/ee/api/cron/sitemaps/queue/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sitemaps/queue/route.ts)
- [apps/web/app/ee/api/cron/bounties/queue-sync-social-metrics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts)
- [apps/web/prisma/schema/job.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/job.prisma)
- [apps/web/lib/cron/with-cron.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/app/ee/api/cron/streams/update-partner-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts)
- [apps/web/lib/jobs/registry.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts)
- [apps/web/lib/jobs/publish-workflows.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/publish-workflows.ts)
- [apps/web/lib/actions/partners/trigger-aggregate-due-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/trigger-aggregate-due-commissions.ts)
- [apps/web/lib/postback/postback-adapters.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts)
- [apps/web/lib/cron/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/index.ts)
- [apps/web/lib/jobs/send-workflows.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts)
- [apps/web/lib/partnerstack/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/importer.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts)
- [apps/web/scripts/dev/test-partner-referrals.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/test-partner-referrals.ts)
## Overview
Background Jobs and Queues manages asynchronous task execution, scheduled background routines, and reliable message transport across the platform. It solves the challenge of handling long-running or deferred operations reliably by integrating a typed job definition framework with Upstash QStash, a transactional Prisma-backed outbox fallback for failed dispatches, and secure webhook execution endpoints. By decoupling heavy workloads from synchronous request lifecycles, the system ensures robust error handling, concurrency control, and automated retry mechanics for periodic crons and distributed workflows.
Sources: [apps/web/lib/jobs/outbox.ts:3-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L3-L15), [apps/web/lib/jobs/index.ts:211-221](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L211-L221), [apps/web/app/api/jobs/process/%5BjobName%5D/route.ts:10-15](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts#L10-L15), [apps/web/prisma/schema/job.prisma:3-15](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/job.prisma#L3-L15), [apps/web/lib/cron/with-cron.ts:23-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L23-L28)
## Job Definition and Registry System
### Overview
The job definition and registry system provides a typed public API for creating background jobs and mapping them within a centralized registry. Using Zod for payload validation, developers define jobs with strict type safety, automatic dispatch helpers, and optional configuration defaults. The registry uses static dynamic imports to map job names to their respective handlers, enabling webpack code-splitting and efficient runtime loading with in-memory caching.
Sources: [apps/web/lib/jobs/index.ts:211-248](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L211-L248), [apps/web/lib/jobs/registry.ts:4-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts#L4-L136)
### Job Loading and Caching Lifecycle
The registry maps registered job names to loader functions that dynamically import job handler modules. When a job is requested, the system inspects an in-memory cache before triggering the loader.
The loading process follows this exact sequence: `loadJob()` checks `jobCache.get(name)` → if cached, returns the job immediately → otherwise looks up the loader in `jobLoaders` → invokes `loader()` to dynamically import the module → validates that `job.name === name` to prevent mismatches → stores the definition in `jobCache` and returns it.
```typescript
export async function loadJob(
name: string,
): Promise {
const cached = jobCache.get(name);
if (cached) return cached;
const loader = jobLoaders[name as keyof typeof jobLoaders];
if (!loader) return undefined;
const job = await loader();
if (job.name !== name) {
throw new Error(`Job name mismatch: ${job.name} !== ${name}`);
}
jobCache.set(name, job);
return job;
}
```
Sources: [apps/web/lib/jobs/registry.ts:118-134](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts#L118-L134)
> [!NOTE]
> Static imports within `jobLoaders` ensure that webpack splits each background job handler into a separate bundle chunk, preventing unnecessary code bloat in core server runtimes.
> Sources: [apps/web/lib/jobs/registry.ts:4-114](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts#L4-L114)
### Registered Job Handlers
The registry currently maps 25 distinct background job handlers covering domain operations, partner management, discounts, and analytics.
| Job Name | Module Import Path |
| :--- | :--- |
| `folder-deleted-job` | `./handlers/folder-deleted-job` |
| `partner-tag-deleted-job` | `./handlers/partner-tag-deleted-job` |
| `unban-partner-job` | `./handlers/unban-partner-job` |
| `link-tag-deleted-job` | `./handlers/link-tag-deleted-job` |
| `domain-deleted-job` | `./handlers/domain-deleted-job` |
| `default-link-deleted-job` | `./handlers/default-link-deleted-job` |
| `create-tremendous-campaign-job` | `./handlers/create-tremendous-campaign-job` |
| `sync-group-utm-job` | `./handlers/sync-group-utm-job` |
| `partner-search-sync-job` | `./handlers/partner-search-sync-job` |
| `process-shopify-order-job` | `./handlers/process-shopify-order-job` |
| `welcome-user-job` | `./handlers/welcome-user-job` |
| `auto-approve-partner-job` | `./handlers/auto-approve-partner-job` |
| `auto-reject-partner-job` | `./handlers/auto-reject-partner-job` |
| `queue-partner-program-summary-job` | `./handlers/queue-partner-program-summary-job` |
| `send-partner-program-summary-job` | `./handlers/send-partner-program-summary-job` |
| `send-connect-payout-reminders-job` | `./handlers/send-connect-payout-reminders-job` |
| `create-custom-commission-job` | `./handlers/create-custom-commission-job` |
| `invalidate-links-for-discounts-job` | `./handlers/invalidate-links-for-discounts-job` |
| `remap-discount-code-job` | `./handlers/remap-discount-code-job` |
| `attach-discount-job` | `./handlers/attach-discount-job` |
| `create-discount-code-for-link-job` | `./handlers/create-discount-code-for-link-job` |
| `publish-discount-codes-creation-job` | `./handlers/publish-discount-codes-creation-job` |
| `aggregate-clicks-job` | `./handlers/aggregate-clicks-job` |
| `process-partner-group-change-job` | `./handlers/process-partner-group-change-job` |
| `program-application-reminder-job` | `./handlers/program-application-reminder-job` |
Sources: [apps/web/lib/jobs/registry.ts:5-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts#L5-L113)
## Dispatching and QStash Transport
### Overview
The transport and dispatch layer controls how job payloads are serialized, wrapped into request envelopes, batched, and published to Upstash QStash. It governs retry logic, error handling, fallback deferral to outbox storage, and request construction for both single and batch dispatches.
Sources: [apps/web/lib/jobs/index.ts:35-209](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L35-L209), [apps/web/lib/jobs/send-jobs.ts:70-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-jobs.ts#L70-L104)
### QStash Transport and Request Building
Job dispatch inputs are transformed into QStash-compatible publish payloads via helper utilities that configure endpoint URLs, request bodies, delays, deduplication headers, and flow control.
```typescript
export function buildQStashJobRequest(
{ name, payload, options }: DispatchJobInput,
opts?: {
dispatchedAt?: string;
batch?: boolean;
notBefore?: number;
},
) {
const envelope: JobEnvelope = {
name,
payload,
dispatchedAt: opts?.dispatchedAt ?? new Date().toISOString(),
};
const notBefore = opts?.notBefore ?? options?.notBefore;
const deduplicationId = buildJobDeduplicationId(
name,
options?.deduplicationId,
);
return {
url: getJobsEndpointUrl(name),
body: envelope,
label: buildJobLabel(name, options?.label),
...(options?.delay &&
opts?.notBefore === undefined && {
delay: options.delay,
}),
...(notBefore && { notBefore }),
...(deduplicationId && { deduplicationId }),
...(options?.retries !== undefined && { retries: options.retries }),
...(options?.flowControl && { flowControl: options.flowControl }),
...(opts?.batch && options?.queue && { queueName: options.queue }),
};
}
```
Sources: [apps/web/lib/jobs/send-jobs.ts:70-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-jobs.ts#L70-L104)
To handle transient network errors when communicating with QStash, publish attempts are wrapped in an exponential backoff retry utility supporting up to 3 retry attempts.
```typescript
async function withQStashRetry(fn: () => Promise): Promise {
for (let attempt = 0; attempt <= QSTASH_PUBLISH_MAX_RETRIES; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt < QSTASH_PUBLISH_MAX_RETRIES) {
await sleep(1000 * Math.pow(2, attempt));
continue;
}
throw error;
}
}
throw new Error("Failed to publish to QStash.");
}
```
Sources: [apps/web/lib/jobs/index.ts:35-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L35-L50)
> [!WARNING]
> If all retry attempts fail during `withQStashRetry`, the error propagates to the dispatch loop, which catches the failure, logs `jobs.publish_failed`, and invokes `deferJobs()` to persist the affected jobs into the database outbox table.
> Sources: [apps/web/lib/jobs/index.ts:143-165](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L143-L165), [apps/web/lib/jobs/index.ts:175-197](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L175-L197)
### Dispatch Flow and Batch Processing
The dispatch pipeline processes collections of job inputs by splitting them into chunks, publishing them to QStash, validating response structures, and falling back to outbox persistence if publishing fails.
The end-to-end dispatch execution walkthrough follows this exact sequence: `dispatchJobs()` checks if inputs are empty → iterates over chunks using `chunk(inputs, QSTASH_BATCH_CHUNK_SIZE)` → calls `publishJobsToQStash(inputChunk)` → inspects each response using `isPublishSuccess(response)` → if successful, increments published count and records message ID → if publishing fails for any item in a chunk, routes failed inputs to `deferJobs()` which calls `persistBackgroundJobs(inputs)` → returns a `DispatchBatchResult` containing counts and individual results.
```typescript
async function publishJobsToQStash(inputs: DispatchJobInput[]) {
if (inputs.length === 0) {
return [];
}
if (inputs.length === 1) {
const input = inputs[0];
const request = buildQStashJobRequest(input);
const response = await withQStashRetry(async () => {
if (input.options?.queue) {
return qstash
.queue({ queueName: input.options.queue })
.enqueueJSON(request);
}
return qstash.publishJSON(request);
});
return [response];
}
const requests = inputs.map((input) =>
buildQStashJobRequest(input, { batch: true }),
);
return withQStashRetry(() => qstash.batchJSON(requests));
}
```
Sources: [apps/web/lib/jobs/index.ts:61-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/index.ts#L61-L88)
| Dispatch Option Property | Type | Purpose |
| :--- | :--- | :--- |
| `delay` | `number \| string` | Delays job execution by a relative duration in seconds. |
| `notBefore` | `number` | Schedules job execution at an absolute Unix timestamp. |
| `deduplicationId` | `string` | Ensures unique message queuing by deduplication identifier suffix. |
| `retries` | `number` | Configures the maximum number of delivery retries on failure. |
| `flowControl` | `FlowControl` | Applies rate limiting and concurrency controls to queue items. |
| `label` | `string` | Attaches a custom grouping label to the QStash request. |
| `queue` | `string` | Routes the job through a specific named QStash queue. |
Sources: [apps/web/lib/jobs/send-jobs.ts:10-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-jobs.ts#L10-L20)
## Execution Webhooks and Signature Verification
### Overview
Background job execution relies on a shared worker endpoint located at `/api/jobs/process/[jobName]` that handles incoming QStash webhook dispatches, verifies request authenticity, validates payloads against job envelopes, and invokes the underlying job definition.
Sources: [apps/web/app/api/jobs/process/%5BjobName%5D/route.ts:10-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts#L10-L106)
> [!NOTE]
> The worker execution route sets `maxDuration = 600` to allow up to 10 minutes for long-running background tasks.
> Sources: [apps/web/app/api/jobs/process/%5BjobName%5D/route.ts:8-8](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts#L8-L8)
### Call-Chain Execution Walkthrough
The request processing pipeline follows a rigorous validation and execution sequence before invoking handler logic. The end-to-end execution path proceeds as follows:
`POST` handler wrapped in `withAxiomBodyLog` → extracts `jobName` from route `params` → clones incoming request and extracts raw text body → `verifyQstashSignature({ req, rawBody })` validates cryptographic headers → `JSON.parse(rawBody)` parses the payload → `jobEnvelopeSchema.safeParse(parsedBody)` validates the job envelope → compares URL `jobName` against envelope `name` → `loadJob(jobName)` retrieves the job definition from the registry → `job.execute(envelope.data.payload)` runs the handler.
Sources: [apps/web/app/api/jobs/process/%5BjobName%5D/route.ts:11-75](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts#L11-L75)
> [!WARNING]
> If a payload fails Zod validation (`z.ZodError`), the worker catches the error and explicitly returns a `200` HTTP status code rather than a `500` error. This guarantees that QStash treats the malformed payload as permanently invalid and halts further automatic retries.
> Sources: [apps/web/app/api/jobs/process/%5BjobName%5D/route.ts:77-88](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/jobs/process/%5BjobName%5D/route.ts#L77-L88)
### Cron and Webhook Authentication via `withCron`
Periodic crons and webhook triggers share a common wrapper utility `withCron` that enforces signature authentication based on the HTTP method before passing control to the downstream handler.
```typescript
export const withCron = (handler: WithCronHandler) => {
return withAxiomBodyLog(
async (
req,
{ params: initialParams }: { params: Promise> },
) => {
const clonedReq = req.clone();
const params = (await initialParams) || {};
const searchParams = getSearchParams(req.url);
try {
let rawBody: string | undefined;
if (req.method === "GET") {
await verifyVercelSignature(req);
} else if (req.method === "POST") {
rawBody = await clonedReq.text();
await verifyQstashSignature({ req, rawBody });
} else {
throw new Error(`Unsupported HTTP method: ${req.method}`);
}
return await handler({
req: clonedReq,
searchParams,
params,
rawBody: rawBody ?? "",
});
} catch (error) {
console.error(error);
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(errorMessage, error);
await logger.flush();
const statusCode =
error instanceof DubApiError
? ErrorCodes[error.code]
: ErrorCodes.internal_server_error;
return logAndRespond(errorMessage, { status: statusCode });
}
},
);
};
```
Sources: [apps/web/lib/cron/with-cron.ts:23-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L23-L76)
| HTTP Method | Authentication Mechanism | Target Provider / Source |
| :--- | :--- | :--- |
| `GET` | `verifyVercelSignature(req)` | Vercel Cron |
| `POST` | `verifyQstashSignature({ req, rawBody })` | Upstash QStash |
Sources: [apps/web/lib/cron/with-cron.ts:39-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L39-L47)
## Transactional Outbox and Failure Recovery
### Overview
When background jobs or workflows fail to publish to Upstash QStash at dispatch time due to network partitions, rate limits, or downstream outages, the system relies on a transactional outbox pattern backed by Prisma and PostgreSQL. Unsent jobs are persisted to the database via `persistBackgroundJobs()` or `persistFailedJobs()`, preserving their payload, options, scheduling time, and attempt counts. A dedicated retry cron endpoint at `/api/cron/queue/retry` regularly polls for due jobs, republishes them through appropriate transport layers, and settles their database state.
Sources: [apps/web/lib/jobs/outbox.ts:45-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L45-L122), [apps/web/app/(ee)/api/cron/queue/retry/route.ts:14-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/queue/retry/route.ts#L14-L16), [apps/web/prisma/schema/job.prisma:1-15](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/job.prisma#L1-L15)
### Prisma Job Schema and Indexing
The `Job` model stores un-dispatched work items. It uses a compound index on `scheduledAt` and `attempts` to allow the retry cron to efficiently locate due jobs that have not exceeded the maximum attempt threshold.
```prisma
model Job {
id String @id
name String
payload Json
options Json? // dispatch options replayed verbatim: deduplicationId, retries, queue, flowControl, label
scheduledAt DateTime @default(now()) // when we should publish the job
attempts Int @default(0)
lastError String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scheduledAt, attempts]) // retry cron: due jobs under the attempt cap
}
```
Sources: [apps/web/prisma/schema/job.prisma:3-15](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/job.prisma#L3-L15)
> [!NOTE]
> The first failed publish attempt initializes `attempts` to 1 via `toJobCreateInput()`, ensuring that the retry budget aligns with `MAX_JOB_ATTEMPTS`.
> Sources: [apps/web/lib/jobs/outbox.ts:26-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L26-L43)
### Retry Cron Polling Flow and Distributed Locking
The retry cron endpoint executes every minute via Vercel Cron. To prevent concurrent executions from overlapping during long-running batch republish cycles, the handler acquires a Redis distributed lock (`lock:queue-retry`) with a 600-second TTL matching the maximum cron duration before invoking `publishPendingJobs()`.
```typescript
export const GET = withCron(async () => {
const acquired = await redis.set(LOCK_KEY, "1", {
nx: true,
ex: LOCK_TTL_SECONDS,
});
if (!acquired) {
return logAndRespond(
"[queue-retry] Another run is in progress. Skipping...",
);
}
try {
const { attempted, published, failed } = await publishPendingJobs();
if (attempted === 0) {
return logAndRespond("No background jobs to retry.");
}
if (failed === 0) {
return logAndRespond(
`Republished ${published} background jobs to QStash.`,
);
}
return logAndRespond(
`Republished ${published} background jobs to QStash; failed to republish ${failed}.`,
);
} finally {
await redis.del(LOCK_KEY);
}
});
```
Sources: [apps/web/app/(ee)/api/cron/queue/retry/route.ts:8-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/queue/retry/route.ts#L8-L47)
Sources: [apps/web/app/(ee)/api/cron/queue/retry/route.ts:8-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/queue/retry/route.ts#L8-L12)
### Call-Chain Execution Walkthrough
The background retry mechanism processes pending outbox entries through a structured multi-step retrieval and settlement pipeline. The full execution sequence proceeds as follows:
`GET` request hits `/api/cron/queue/retry` wrapped in `withCron` → acquires Redis distributed lock `lock:queue-retry` with `nx: true` and 600s TTL → `publishPendingJobs()` queries Prisma for due records where `scheduledAt <= now()` and `attempts < MAX_JOB_ATTEMPTS` ordered by `createdAt` asc with a take limit of `MAX_JOBS_PER_BATCH` → matches jobs against transport definitions (`isWorkflowName` or `isDefineJobName`) → dispatches batches via `triggerWorkflows` or `sendJobs` → `settlePublishResults({ results, jobs })` evaluates outcomes → successfully published job IDs are deleted via `prisma.job.deleteMany()` → failed publish results group error messages and update records via `prisma.job.updateMany()` incrementing `attempts` and setting `lastError` → if any job reaches `MAX_JOB_ATTEMPTS`, an error event `jobs.retry_exhausted` is logged to Axiom.
Sources: [apps/web/lib/jobs/outbox.ts:125-220](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L125-L220), [apps/web/app/(ee)/api/cron/queue/retry/route.ts:16-46](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/queue/retry/route.ts#L16-L46)
### Outbox Transport Configuration and Error Handling
Job transport routing maps specific job naming conventions to their respective dispatch handlers using a transport array.
| Matcher Function | Transport Handler | Purpose |
| :--- | :--- | :--- |
| `isWorkflowName` | `triggerWorkflows` | Handles workflow execution steps and triggers |
| `isDefineJobName` | `sendJobs` | Handles standard typed background jobs |
Sources: [apps/web/lib/jobs/outbox.ts:10-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L10-L24)
> [!CAUTION]
> When `persistFailedJobs()` encounters a database error while attempting to record failed dispatches, it catches the exception and logs it via Axiom (`jobs.dispatch_lost`) rather than rethrowing, swallowing the error to prevent dispatch request failures from crashing upstream API routes.
> Sources: [apps/web/lib/jobs/outbox.ts:45-84](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/outbox.ts#L45-L84)
## Workflow Orchestration and Batch Enqueuing
### Overview
Workflow orchestration and batch enqueuing provide mechanisms for triggering complex workflow steps and pacing batch enqueue operations. The system coordinates scheduled campaign fan-out, transactional workflow intervals, and Upstash QStash batch transmissions.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:18-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L18-L26), [apps/web/lib/cron/enqueue-batch-jobs.ts:17-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/enqueue-batch-jobs.ts#L17-L18)
### Batch Enqueue and Retry Mechanism
The `enqueueBatchJobs` function handles batch delivery to QStash with built-in retry logic and exponential backoff.
Sources: [apps/web/lib/cron/enqueue-batch-jobs.ts:17-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/enqueue-batch-jobs.ts#L17-L18)
```typescript
export async function enqueueBatchJobs(jobs: EnqueueBatchJobsProps[]) {
const maxRetries = 3;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await qstash.batchJSON(jobs);
} catch (error) {
if (attempt < maxRetries) {
await sleep(1000 * Math.pow(2, attempt));
continue;
}
await log({
message: `[enqueueBatchJobs] Failed to enqueue batch jobs: ${JSON.stringify(error, null, 2)}`,
type: "errors",
mention: true,
});
throw new Error(
`Failed to enqueue batch jobs: ${JSON.stringify(error, null, 2)}`,
);
}
}
}
```
Sources: [apps/web/lib/cron/enqueue-batch-jobs.ts:18-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/enqueue-batch-jobs.ts#L18-L41)
> [!WARNING]
> If all retry attempts fail in `enqueueBatchJobs`, an error is logged with mention tagging via `log()` before throwing an unhandled `Error`, which can disrupt upstream campaign queueing cron executions.
> Sources: [apps/web/lib/cron/enqueue-batch-jobs.ts:30-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/enqueue-batch-jobs.ts#L30-L39)
### Workflow Dispatch and Validation
Workflows are validated against strict kebab-case naming rules and mapped to explicit API endpoints before dispatching requests via the QStash workflow client.
Sources: [apps/web/lib/jobs/send-workflows.ts:20-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L20-L38), [apps/web/lib/jobs/send-workflows.ts:82-95](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L82-L95)
| Workflow Name | Target Path |
| :--- | :--- |
| `partner-approved-workflow` | `/api/workflows/partner-approved` |
| `merge-partner-accounts-workflow` | `/api/workflows/merge-partner-accounts` |
| `create-partner-commission-workflow` | `/api/workflows/create-partner-commission` |
| `reattribute-customer-workflow` | `/api/workflows/reattribute-customer` |
| `detach-discount-workflow` | `/api/workflows/detach-discount` |
Sources: [apps/web/lib/jobs/send-workflows.ts:27-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L27-L34)
### Scheduled Campaign Queueing Call-Chain
The scheduled campaign queueing cron endpoint processes transactional and marketing campaigns through a structured paging and batching execution sequence.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:20-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L20-L26)
`GET` request hits `/api/cron/campaigns/queue-scheduled` wrapped in `withCron` → `Promise.allSettled()` invokes `queueTransactionalCampaigns(now)` and `queueMarketingCampaigns(now)` in parallel → `queueTransactionalCampaigns` verifies `isTransactionalTick(now)` (checking if UTC hour % 12 === 0 and UTC minute < 5) → queries `prisma.campaign.findMany` with pagination cursor `lastCampaignId` and `CRON_BATCH_SIZE` taking active transactional campaigns with enabled workflows → filters scheduled workflows via `isScheduledWorkflow` → dispatches batches via `enqueueBatchJobs()` pointing to `/api/cron/workflows/${workflow.id}` with parallelism set to 10 → `queueMarketingCampaigns` queries scheduled marketing campaigns where `scheduledAt <= now` → dispatches batches via `enqueueBatchJobs()` pointing to `/api/cron/campaigns/broadcast` with parallelism set to 1 → error handling aggregates rejections and logs failure via `log()` if any.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:20-178](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L20-L178)
> [!TIP]
> The 5-minute transaction tick window (`isTransactionalTick`) absorbs Vercel cron jitter, while QStash deduplication lasting 10 minutes absorbs duplicate publishes without leaking extra triggers after expiry.
> Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:58-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L58-L69)
## Periodic Crons and Stream Processing
### Overview
Periodic cron jobs and Redis stream consumers automate scheduled background operations, batch event processing, and periodic maintenance tasks across Dub's enterprise tier. These routines are centrally protected by authentication wrappers that verify invocation signatures from external schedulers and message brokers.
Sources: [apps/web/lib/cron/with-cron.ts:23-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L23-L76), [apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts:1-11](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts#L1-L11)
### Cron Endpoint Authentication
Periodic crons and webhook triggers share a common wrapper utility `withCron` that enforces signature authentication based on the HTTP method before passing control to the downstream handler.
#### Call-Chain Execution Walkthrough
`POST`/`GET` request received at cron route → wrapped handler executes via `withAxiomBodyLog` → request is cloned (`req.clone()`) to allow body inspection while preserving the original stream for Axiom logging → `params` and `searchParams` are extracted from the URL → request method branch evaluates:
- If `GET`, `verifyVercelSignature(req)` validates the invocation against Vercel Cron secrets.
- If `POST`, `clonedReq.text()` reads the raw body text, and `verifyQstashSignature({ req, rawBody })` verifies the Upstash QStash signature.
- If any other method is encountered, an unsupported method error is thrown.
→ The inner cron task handler executes with structured arguments (`req`, `searchParams`, `params`, `rawBody`) → Any caught errors are logged to Axiom via `logger.error()` and flushed before translating the error into an appropriate HTTP status code via `ErrorCodes` and returning a standardized error response.
Sources: [apps/web/lib/cron/with-cron.ts:24-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L24-L75)
> [!NOTE]
> `withCron` clones the incoming request early in the middleware lifecycle so individual cron handlers can read request bodies without mutating or consuming the stream required by Axiom audit logging.
> Sources: [apps/web/lib/cron/with-cron.ts:29-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/cron/with-cron.ts#L29-L31)
### Scheduled Tasks and Fan-Out Crons
Periodic background endpoints handle tasks ranging from program application reminders and social metrics queueing to reward processing and commission aggregation.
Sources: [apps/web/app/(ee)/api/cron/program-application-reminder/route.ts:7-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts#L7-L13), [apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts:11-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts#L11-L49), [apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:10-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts#L10-L40), [apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:13-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L13-L68)
| Cron Route | HTTP Method | Max Duration | Purpose |
| :--- | :--- | :--- | :--- |
| `/api/cron/program-application-reminder` | `POST` | Default | Sends reminders to unverified program applicants |
| `/api/cron/bounties/queue-sync-social-metrics` | `GET` | Default | Queues social metric synchronization batches for submission bounties |
| `/api/cron/rewards/queue-custom-commissions` | `GET` | 600s | Fans out custom commission generation jobs for due rewards |
| `/api/cron/sitemaps/queue` | `GET`/`POST` | Default | Paging loop that queues workspace sitemap imports via QStash |
| `/api/cron/trial-emails` | `GET`/`POST` | Default | Executes paid-plan trial marketing email sequences recursively |
| `/api/cron/payouts/aggregate-due-commissions` | `GET` | 600s | Aggregates eligible pending commissions into partner payouts |
Sources: [apps/web/app/(ee)/api/cron/program-application-reminder/route.ts:7-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts#L7-L13), [apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts:11-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts#L11-L49), [apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:10-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts#L10-L40), [apps/web/app/(ee)/api/cron/sitemaps/queue/route.ts:9-103](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/sitemaps/queue/route.ts#L9-L103), [apps/web/app/(ee)/api/cron/trial-emails/route.ts:9-63](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts#L9-L63), [apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:13-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L13-L68)
### Redis Stream Event Consumers
High-frequency telemetry such as click events and partner activity logs are ingested into Redis streams and processed in batches by dedicated stream consumers.
Sources: [apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts:1-170](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts#L1-L170), [apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts:1-370](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts#L1-L370)
The click stats consumer pulls entries up to a defined batch size, aggregates metrics by link, workspace, and program enrollment, and commits database writes across parallel sub-batches. Similarly, the partner activity stream consumer groups events by program-partner pairs, queries grouped link and commission statistics in parallel from Prisma, computes derived performance metrics (such as net revenue, earnings per click, and consistency scores), and flushes updates via raw PlanetScale SQL execution.
Sources: [apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts:15-170](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts#L15-L170), [apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts:15-343](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts#L15-L343)
> [!CAUTION]
> Stream processors define concurrency locks and batch limits (such as `BATCH_SIZE = 10_000` for clicks and `BATCH_SIZE = 6000` for partner activities) to prevent overlapping invocations from double-counting metrics or exhausting database connection pools.
> Sources: [apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts:15-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts#L15-L20), [apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts:15-15](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts#L15-L15)
## Related
- [[Payout Processing]]
- [[Campaign Broadcaster]]
---
## Technical docs: GET Search a .link domain availability
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/searchdomainavailability
## Parameters
## Responses
## Try It
---
## Technical docs: Link Resolution and Redirection
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/link-resolution-and-redirection
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/app/ee/api/partners/links/upsert/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/upsert/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/app/domain/expired/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/expired/page.tsx)
- [apps/web/app/api/links/exists/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/exists/route.ts)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts)
- [apps/web/app/domain/notfound/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx)
- [apps/web/lib/planetscale/get-link-via-edge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts)
- [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)
- [apps/web/lib/api/links/process-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts)
- [apps/web/app/domain/key/proxy/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx)
- [apps/web/app/api/links/info/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/info/route.ts)
- [apps/web/lib/api/links/utils/process-key.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts)
- [apps/web/app/domain/banned/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/banned/page.tsx)
- [apps/web/app/ee/api/cron/domains/update/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts)
- [apps/web/lib/planetscale/get-link-with-partner.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-with-partner.ts)
- [apps/web/app/domain/key/inspect/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx)
- [apps/web/app/api/links/random/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/random/route.ts)
- [apps/web/app/api/links/iframeable/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/iframeable/route.ts)
- [apps/web/app/ee/api/admin/links/ban/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/links/ban/route.ts)
- [apps/web/app/cloaked/url/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx)
- [apps/web/lib/middleware/utils/get-final-url.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts)
- [apps/web/lib/api/partners/generate-partner-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts)
- [packages/cli/src/api/links.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts)
- [apps/web/app/api/callback/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/bitly/route.ts)
- [packages/utils/src/functions/link-constructor.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/functions/link-constructor.ts)
- [apps/web/lib/api/links/get-link-or-throw.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/get-link-or-throw.ts)
- [apps/web/app/domain/key/inspect/card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/card.tsx)
## Overview
Link resolution and redirection form the core traffic-routing engine of Dub, capturing inbound short-link requests at the edge and mapping them to destination URLs, target assets, or administrative fallback pages. This system addresses the challenges of low-latency distributed lookups, case sensitivity, internationalized domain names, and granular tracking across multi-tenant workspaces. Key design decisions include multi-tier caching with Redis and edge databases, on-demand fallback crawls for legacy providers, and flexible URL parameter preservation. By integrating directly with edge middleware, analytics, and partner attribution pipelines, the resolution subsystem ensures fast, secure, and context-aware request handling worldwide. Sources: [apps/web/lib/middleware/link.ts:43-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L122), [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39)
## Edge Link Resolution Pipeline
### Overview
Dub captures and resolves inbound traffic at the network edge using Next.js middleware and edge-optimized database queries. When a request hits the edge, the system first inspects the hostname and path through global routing checks before delegating link resolution to caching layers or directly querying the underlying relational datastore via PlanetScale. Sources: [apps/web/middleware.ts:34-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L89), [apps/web/lib/middleware/link.ts:43-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L104)
### Edge Request Lifecycle and Middleware Entry
Incoming requests enter through `middleware.ts`, which sets up a runtime environment and parses the request context using `parse(req)`. The request is evaluated against known hostnames—such as application dashboards, API endpoints, or administrative portals—before falling through to the short-link resolution pipeline in `LinkMiddleware`. Sources: [apps/web/middleware.ts:20-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L20-L89), [apps/web/lib/middleware/link.ts:43-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L44)
```mermaid
sequenceDiagram
participant middleware.ts
participant LinkMiddleware
participant getLinkViaEdge
participant getLinkViaEdgeHelper
middleware.ts->>LinkMiddleware: POST / Inbound Request
LinkMiddleware->>getLinkViaEdge: getLinkViaEdge({ domain, key })
getLinkViaEdge->>getLinkViaEdgeHelper: getLinkViaEdgeHelper({ domain, key })
```
Sources: [apps/web/middleware.ts:34-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L89), [apps/web/lib/middleware/link.ts:43-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L104), [apps/web/lib/planetscale/get-link-via-edge.ts:10-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L68)
### Call-Chain Execution Walkthrough
The edge resolution path executes a precise sequence of functions to fetch link records from the edge database when cache misses occur:
1. `POST` (or incoming middleware invocation) receives the initial request payload or URL path. Sources: [apps/web/app/ee/api/track/open/route.ts:23-97](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L23-L97)
2. `getLinkViaEdge` checks an in-flight lookup map (`inFlightLinkLookups`) using a composite `${domain}:${key}` string to deduplicate concurrent requests for the same short link. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:41-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L41-L68)
3. `getLinkViaEdgeHelper` normalizes the domain's case sensitivity, applies punycode and URI decoding, and executes a prepared SQL query (`SELECT * FROM Link WHERE domain = ? AND \`key\` = ?`) against the database connection. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39)
> [!NOTE]
> `getLinkViaEdge` uses an in-memory `Map` called `inFlightLinkLookups` to prevent duplicate database queries when multiple concurrent requests arrive for the exact same uncached link, sharing the resulting promise across callers. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:41-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L41-L68)
### Core Resolution Functions and Parameters
The edge link resolution layer relies on specialized utility modules to normalize identifiers, handle deduplication, and query persistent storage.
| Function Name | File Location | Purpose & Behavior |
| :--- | :--- | :--- |
| `LinkMiddleware` | `apps/web/lib/middleware/link.ts` | Main entry point for short-link resolution, cache checks, and click tracking initialization. Sources: [apps/web/lib/middleware/link.ts:43-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L122) |
| `getLinkViaEdge` | `apps/web/lib/planetscale/get-link-via-edge.ts` | Deduplicates concurrent database lookups using an in-flight lookup cache. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:46-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L46-L68) |
| `getLinkViaEdgeHelper` | `apps/web/lib/planetscale/get-link-via-edge.ts` | Formats query keys based on domain case sensitivity and executes the MySQL link lookup. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39) |
| `POST` | `apps/web/app/ee/api/track/open/route.ts` | Handles deep link open tracking events, validating redis caches and invoking edge lookups on misses. Sources: [apps/web/app/ee/api/track/open/route.ts:23-110](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L23-L110) |
Sources: [apps/web/lib/middleware/link.ts:43-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L122), [apps/web/lib/planetscale/get-link-via-edge.ts:10-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L68), [apps/web/app/ee/api/track/open/route.ts:23-110](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L23-L110)
### Design Trade-Offs in Edge Resolution
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| In-flight lookup deduplication via local `Map` | Prevents database connection exhaustion during traffic spikes on popular uncached links. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:41-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L41-L68) | Transient memory overhead in the edge runtime node for active lookup promises. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:41-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L41-L68) |
| Dual caching via Redis and fallback PlanetScale queries | Ensures high availability and sub-millisecond lookups under normal operation while handling cache failures gracefully. Sources: [apps/web/lib/middleware/link.ts:89-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L104) | Increased architectural complexity managing synchronization and failover states. Sources: [apps/web/lib/middleware/link.ts:89-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L104) |
| Case-sensitivity domain checks prior to key encoding | Preserves case preservation options for custom enterprise domains where case matters. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:17-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L17-L24) | Requires conditional branching during query key formatting. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:17-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L17-L24) |
Sources: [apps/web/lib/middleware/link.ts:89-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L104), [apps/web/lib/planetscale/get-link-via-edge.ts:10-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L68)
## Domain Normalization and Key Encoding
### Overview
Domain normalization and key encoding dictate how raw URL path segments and hostnames are transformed into queries suitable for persistent storage and database lookups. Depending on whether a domain is case-sensitive, keys undergo distinct transformation pipelines involving URI decoding, Unicode normalization, and punycode encoding. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39), [apps/web/lib/api/links/utils/process-key.ts:8-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L8-L41)
### Key Processing and Sanitization Pipeline
The `processKey` utility handles validation and sanitization for link keys before they are stored or queried. It evaluates reserved routes, regular expression constraints, and default Dub domain specifics. Sources: [apps/web/lib/api/links/utils/process-key.ts:8-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L8-L41)
1. If the key equals `_root`, it returns immediately. Sources: [apps/web/lib/api/links/utils/process-key.ts:8-12](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L8-L12)
2. It validates the key against `validKeyRegex` and rejects any key starting with an underscore `_` (reserved for Dub internals) or flagged by `isUnsupportedKey`. Sources: [apps/web/lib/api/links/utils/process-key.ts:13-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L13-L25)
3. It strips all leading and trailing slashes using `key.replace(/^\/+|\/+$/g, "")`. Sources: [apps/web/lib/api/links/utils/process-key.ts:26-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L26-L28)
4. For default Dub domains, it applies Unicode normalization (`NFD`) and strips accents/diacritical marks (`/[\u0300-\u036f]/g`) to prevent phishing and typo squatting. Sources: [apps/web/lib/api/links/utils/process-key.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L34-L36)
5. It encodes the resulting string to ASCII via `punyEncode`. Sources: [apps/web/lib/api/links/utils/process-key.ts:37-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L37-L40)
> [!WARNING]
> Keys starting with an underscore are strictly reserved for Dub internal routes and will cause `processKey` to return `null`, preventing custom links from utilizing leading underscores. Sources: [apps/web/lib/api/links/utils/process-key.ts:17-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L17-L20)
### Call-Chain Execution Walkthrough
When an edge lookup executes, domain case sensitivity determines how keys are prepared for querying:
1. `POST` extracts the hostname and pathname from the incoming request URL. Sources: [apps/web/app/ee/api/track/open/route.ts:25-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L25-L79)
2. `getLinkViaEdge` passes the domain and key into `getLinkViaEdgeHelper`. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:46-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L46-L68)
3. `getLinkViaEdgeHelper` evaluates `isCaseSensitiveDomain(domain)` to branch between case-sensitive encoding (`encodeKey(key)`) and non-case-sensitive punycode/URI decoding (`punyEncode(safeDecodeURIComponent(key))`). Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:10-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L24)
```mermaid
sequenceDiagram
participant Route as POST (route.ts)
participant Edge as getLinkViaEdge (get-link-via-edge.ts)
participant Helper as getLinkViaEdgeHelper (get-link-via-edge.ts)
Route->>Edge: Invoke with { domain, key }
Edge->>Helper: Call helper function
Helper->>Helper: Check isCaseSensitiveDomain(domain)
Helper->>Helper: Encode or punyEncode(safeDecodeURIComponent(key))
Helper->>Database: Execute SQL SELECT query
```
Sources: [apps/web/app/ee/api/track/open/route.ts:75-97](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L75-L97), [apps/web/lib/planetscale/get-link-via-edge.ts:10-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L68)
### Domain and Link Transformation Reference
| Function / Utility | Source File | Behavior & Transformation Purpose |
| :--- | :--- | :--- |
| `processKey` | `apps/web/lib/api/links/utils/process-key.ts` | Validates regex, rejects reserved underscores, strips slashes, and applies NFD normalization for Dub domains. Sources: [apps/web/lib/api/links/utils/process-key.ts:8-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L8-L41) |
| `linkConstructor` | `packages/utils/src/functions/link-constructor.ts` | Constructs full URLs with punycode-encoded domains and keys, optionally stripping protocols if `pretty` is true. Sources: [packages/utils/src/functions/link-constructor.ts:3-29](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/functions/link-constructor.ts#L3-L29) |
| `linkConstructorSimple` | `packages/utils/src/functions/link-constructor.ts` | Builds direct URLs without punycode transformations using raw domain and key inputs. Sources: [packages/utils/src/functions/link-constructor.ts:31-39](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/functions/link-constructor.ts#L31-L39) |
| `getLinkViaEdgeHelper` | `apps/web/lib/planetscale/get-link-via-edge.ts` | Formats query keys conditional on domain case sensitivity before executing MySQL lookups. Sources: [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39) |
Sources: [apps/web/lib/api/links/utils/process-key.ts:8-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/process-key.ts#L8-L41), [packages/utils/src/functions/link-constructor.ts:3-39](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/functions/link-constructor.ts#L3-L39), [apps/web/lib/planetscale/get-link-via-edge.ts:10-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/planetscale/get-link-via-edge.ts#L10-L39)
## Final URL Construction and Parameters
### Overview
Final URL assembly integrates base targets, A/B test variant splits, attribution parameters, and incoming query strings at the edge. The resolution routine evaluates cached link properties, resolves variant destinations via `resolveABTestURL`, and constructs the outgoing redirection destination using `getFinalUrl`. Sources: [apps/web/lib/middleware/link.ts:168-173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L168-L173), [apps/web/lib/middleware/utils/get-final-url.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L12-L23)
### Call-Chain Execution Walkthrough
The construction of a resolved destination URL flows through specific middleware and utility stages before returning a final redirect string:
1. `LinkMiddleware` extracts cached link properties including `testVariants` and `testCompletedAt`. Sources: [apps/web/lib/middleware/link.ts:150-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L150-L171)
2. `resolveABTestURL` evaluates the active variants to select a target URL if an A/B test is active. Sources: [apps/web/lib/middleware/link.ts:168-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L168-L171)
3. The resulting `testUrl` or `cachedLink.url` is passed to `getFinalUrl` along with the request object and `clickId`. Sources: [apps/web/lib/middleware/link.ts:173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L173)
4. `getFinalUrl` parses query parameters, injects attribution overrides (such as `dub_id` or Stripe parameters), and appends pass-through query parameters from the incoming request. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:25-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L25-L125)
```mermaid
sequenceDiagram
participant Middleware as LinkMiddleware (link.ts)
participant ABTest as resolveABTestURL
participant FinalUrl as getFinalUrl (get-final-url.ts)
Middleware->>ABTest: Evaluate testVariants & testCompletedAt
ABTest-->>Middleware: Return testUrl (or fallback to cachedLink.url)
Middleware->>FinalUrl: Invoke with target url, req, and clickId
FinalUrl->>FinalUrl: Inject attribution & pass-through query parameters
FinalUrl-->>Middleware: Return fully constructed URL string
```
Sources: [apps/web/lib/middleware/link.ts:168-173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L168-L173), [apps/web/lib/middleware/utils/get-final-url.ts:12-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L12-L125)
### Query Parameter Transformation Reference
| Parameter or Rule | Target URL Condition | Action & Transformation Behavior |
| :--- | :--- | :--- |
| `dub_client_reference_id` | Stripe payment links (`dub_client_reference_id === "1"`) | Replaced with `client_reference_id=dub_id_${clickId}` and the original key is deleted. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:41-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L41-L44) |
| `dub_id` | General links (when `dub-no-track` is absent) | Injected as `dub_id=${clickId}` to track user conversion attribution. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:47-49](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L47-L49) |
| `pid`, `clickid`, `c`, `af_siteid` | AppsFlyer tracking URLs (`isAppsFlyerTrackingUrl`) | Sets hardcoded `pid=dubinc_int`, passes `clickid`, and populates campaign/site IDs via `via` if missing. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:53-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L53-L69) |
| `cl`, `ua`, `ip`, `wpcn`, `wpcl` | Singular tracking URLs (`isSingularTrackingUrl`) | Injects click ID, user agent, IP address, and polyfills integration placeholders like `{via}` and `{dub_id}`. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:72-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L72-L89) |
| `referrer` | Google Play Store URLs (`isGooglePlayStoreUrl`) | Prepends deep link parameters into the existing encoded referrer string. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:92-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L92-L101) |
| Pass-through parameters | All links (excluding `dub-no-track` and `redir_url`) | Appends or overwrites incoming search parameters from the request onto the destination URL. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:107-112](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L107-L112) |
Sources: [apps/web/lib/middleware/utils/get-final-url.ts:41-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L41-L113)
> [!WARNING]
> Internal query parameters like `dub-no-track` and redirection control parameters (`redir_url`) are explicitly filtered out during pass-through parameter iteration, preventing them from leaking into external destination URLs. Sources: [apps/web/lib/middleware/utils/get-final-url.ts:108-112](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-final-url.ts#L108-L112)
### Partner Link Generation and Attribution Overrides
For partner program enrollments and external integrations, partner links undergo generation via `generatePartnerLink` where keys are derived from usernames, names, or emails, and integration-specific parameters are appended. Sources: [apps/web/lib/api/partners/generate-partner-link.ts:16-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L16-L40)
> [!NOTE]
> When `appsFlyerParameters` are supplied during partner link generation, `generatePartnerLink` processes target URLs via `applyAppsFlyerParameters`, interpolating partner context names and link keys directly into the attribution stream. Sources: [apps/web/lib/api/partners/generate-partner-link.ts:147-161](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L147-L161)
## Terminal States and Administrative Restrictions
### Overview
When a requested link fails resolution due to expiration, missing records, or administrative enforcement, Dub routes requests to dedicated terminal pages or executes administrative restriction pipelines. Expired and not-found pages support custom domain-level redirect configurations, whereas banned links invoke backend administrative actions that isolate resources under legal compliance entities. Sources: [apps/web/app/domain/expired/page.tsx:34-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/expired/page.tsx#L34-L42), [apps/web/app/domain/notfound/page.tsx:35-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L35-L43), [apps/web/app/ee/api/admin/links/ban/route.ts:14-46](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/links/ban/route.ts#L14-L46)
### Terminal Pages and Custom Redirection Logic
The expired and not-found route handlers accept dynamic domain parameters, query the primary database via Prisma to inspect custom domain fallback properties, and conditionally trigger Next.js navigation redirects if configuration values exist. Sources: [apps/web/app/domain/expired/page.tsx:30-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/expired/page.tsx#L30-L42), [apps/web/app/domain/notfound/page.tsx:31-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L31-L43)
| Terminal Page | Route Path | Database Fallback Property | Default Metadata Title | UI Placeholder Icon |
| :--- | :--- | :--- | :--- | :--- |
| Expired Link | `/[domain]/expired` | `domainData.expiredUrl` | `Expired Link` | `CircleHalfDottedClock` |
| Link Not Found | `/[domain]/notfound` | `domainData.notFoundUrl` | `Link Not Found` | `GlobeSearch` |
| Banned Link | `/[domain]/banned` | None | `Banned Link` | `ShieldSlash` |
Sources: [apps/web/app/domain/expired/page.tsx:14-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/expired/page.tsx#L14-L49), [apps/web/app/domain/notfound/page.tsx:14-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L14-L50), [apps/web/app/domain/banned/page.tsx:12-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/banned/page.tsx#L12-L34)
> [!NOTE]
> All three terminal page components export `revalidate = false` to cache responses indefinitely, and configure `generateStaticParams()` to return an empty array for on-demand static generation. Sources: [apps/web/app/domain/expired/page.tsx:12-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/expired/page.tsx#L12-L28), [apps/web/app/domain/notfound/page.tsx:12-29](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/notfound/page.tsx#L12-L29), [apps/web/app/domain/banned/page.tsx:10-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/banned/page.tsx#L10-L25)
### Administrative Link Banning Execution
The administrative link banning endpoint (`DELETE /api/admin/links/ban`) is protected by owner-level admin checks and processes incoming query parameters through the domain key schema to identify target resources. Sources: [apps/web/app/ee/api/admin/links/ban/route.ts:13-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/links/ban/route.ts#L13-L20)
```typescript
export const DELETE = withAdmin(
async ({ searchParams }) => {
const { domain, key } = domainKeySchema.parse(searchParams);
const link = await prisma.link.findUnique({
where: { domain_key: { domain, key } },
});
if (!link) {
return NextResponse.json({ error: "Link not found" }, { status: 404 });
}
const urlDomain = getDomainWithoutWWW(link.url);
const response = await Promise.all([
prisma.link.update({
where: { id: link.id },
data: {
userId: LEGAL_USER_ID,
projectId: LEGAL_WORKSPACE_ID,
},
}),
linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }),
urlDomain && updateConfig({ key: "domains", value: urlDomain }),
]);
return NextResponse.json(response);
},
{ requiredRoles: ["owner"] },
);
```
Sources: [apps/web/app/ee/api/admin/links/ban/route.ts:14-53](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/links/ban/route.ts#L14-L53)
> [!WARNING]
> Banning a link reassigns its ownership properties (`userId` and `projectId`) to `LEGAL_USER_ID` and `LEGAL_WORKSPACE_ID`, immediately removing management access from standard workspace members while updating the edge cache and edge configuration domains. Sources: [apps/web/app/ee/api/admin/links/ban/route.ts:28-46](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/links/ban/route.ts#L28-L46)
## Deep Linking and Cloaked Previews
### Overview
The deep linking and preview subsystem controls mobile OS routing, app store redirection, iframe cloaking, and metadata proxy inspection. Mobile deep link handling starts at `DeepLinkPreviewPage`, which parses request headers, evaluates the client operating system via Next.js `userAgent`, and queries Prisma for domain-level asset configurations. Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:45-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx#L45-L90)
### Mobile Deep Link Routing and Redirection
When a request enters the deep link handler, the system performs a multi-step platform validation to determine whether to display a deep view preview card or trigger an immediate platform redirect. Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:58-137](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx#L58-L137)
```mermaid
sequenceDiagram
autonumber
participant Client as Mobile Client
participant Page as DeepLinkPreviewPage
participant DB as Prisma Database
Client->>Page: GET /deeplink/[domain]/[[...key]]
Page->>Page: Detect OS via userAgent (iOS / Android)
Page->>DB: prisma.link.findUnique (domain & encodedKey)
DB-->>Page: Link & shortDomain data
alt Link missing
Page-->>Client: redirect(https://[domain])
else Missing deep linking setup (assetLinks / AASA)
Page-->>Client: redirect(platform fallback URL or link.url)
else Valid Deep View setup
Page->>Page: Parse deepviewData, validate Android package name
Page-->>Client: Render Deep View preview page with badge & action button
end
```
Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:58-137](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx#L58-L137)
> [!WARNING]
> If a short domain lacks `appleAppSiteAssociation` or `assetLinks` configurations combined with valid `deepviewData`, the preview page is bypassed entirely, immediately issuing a server-side redirect to `link.ios`, `link.android`, or the canonical `link.url`. Sources: [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx:102-110](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx#L102-L110)
### Metadata Proxy and Link Inspector Architecture
Dub provides dedicated routes for metadata proxying and link inspection, allowing clients to examine short links or render masked link previews safely via edge runtimes. Sources: [apps/web/app/domain/key/proxy/page.tsx:1-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx#L1-L34), [apps/web/app/domain/key/inspect/page.tsx:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L1-L39), [apps/web/app/cloaked/url/page.tsx:1-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx#L1-L38)
| Route File Path | Runtime | Primary Function | Metadata Extraction Source |
| :--- | :--- | :--- | :--- |
| `apps/web/app/[domain]/[key]/proxy/page.tsx` | Node / Default | Renders proxy card with preview image and favicon | `getLinkViaEdge` Sources: [apps/web/app/domain/key/proxy/page.tsx:11-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx#L11-L34) |
| `apps/web/app/[domain]/[key]/inspect/page.tsx` | `edge` | Renders interactive `LinkInspectorCard` and `LinkPreview` | `getLinkViaEdge` Sources: [apps/web/app/domain/key/inspect/page.tsx:15-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L15-L39) |
| `apps/web/app/cloaked/[url]/page.tsx` | Node / Default | Renders full-screen iframe pointing to destination URL | `getMetaTags(url)` Sources: [apps/web/app/cloaked/url/page.tsx:22-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx#L22-L38) |
| `apps/web/app/api/links/iframeable/route.ts` | `edge` | Validates if a destination URL permits embedding in iframes | `isIframeable` Sources: [apps/web/app/api/links/iframeable/route.ts:10-22](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/iframeable/route.ts#L10-L22) |
Sources: [apps/web/app/domain/key/proxy/page.tsx:1-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/proxy/page.tsx#L1-L34), [apps/web/app/domain/key/inspect/page.tsx:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/inspect/page.tsx#L1-L39), [apps/web/app/cloaked/url/page.tsx:1-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx#L1-L38), [apps/web/app/api/links/iframeable/route.ts:10-22](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/iframeable/route.ts#L10-L22)
> [!TIP]
> The `/api/links/iframeable` endpoint enforces rate limiting via `ratelimitOrThrow(req, "iframeable")` prior to invoking `isIframeable` to prevent abuse of external target inspection. Sources: [apps/web/app/api/links/iframeable/route.ts:17-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/iframeable/route.ts#L17-L20)
### Cloaked URL Destination Resolution
When requests hit the cloaked URL handler at `apps/web/app/cloaked/[url]/page.tsx`, `getCloakedDestinationUrl` evaluates whether the dynamic route parameter requires double-decoding. Sources: [apps/web/app/cloaked/url/page.tsx:8-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx#L8-L20)
```typescript
function getCloakedDestinationUrl(param: string): string {
if (/^https?%3A/i.test(param)) {
try {
return decodeURIComponent(param);
} catch {
return param;
}
}
return param;
}
```
Sources: [apps/web/app/cloaked/url/page.tsx:8-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/cloaked/%5Burl%5D/page.tsx#L8-L20)
## Legacy Crawling and Resolution Fallbacks
### Overview
When an incoming link lookup results in a cache and edge database miss, the resolution pipeline invokes specialized fallback handlers to recover the link or ingest it dynamically. For legacy Bitly short links matching specific domains such as `buff.ly`, the link middleware delegates resolution to an on-demand crawler that queries the Bitly API, materializes the record into the local database, and issues a redirection. Sources: [apps/web/lib/middleware/link.ts:100-117](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L100-L117), [apps/web/lib/middleware/utils/crawl-bitly.ts:20-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L68)
```mermaid
sequenceDiagram
autonumber
participant Client as Client Request
participant MW as LinkMiddleware
participant DB as Edge DB / Cache
participant Bitly as Bitly API
participant PG as Prisma Database
Client->>MW: Inbound Request (domain, key)
MW->>DB: linkCache.get() / getLinkViaEdge()
DB-->>MW: Miss (null)
alt Domain is buff.ly
MW->>Bitly: crawlBitly() → fetchBitlyLink()
Bitly-->>MW: { long_url, created_at }
MW->>PG: prisma.link.create() (Buffer Workspace)
MW->>Client: NextResponse.redirect(long_url, 302)
else Standard Domain Miss
MW-->>Client: Rewrite to /[domain]/notfound
end
```
Sources: [apps/web/lib/middleware/link.ts:100-117](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L100-L117), [apps/web/lib/middleware/utils/crawl-bitly.ts:20-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L68)
### Bitly Legacy Crawler and On-Demand Ingestion
The `crawlBitly` utility inspects the request parameters and validates the key against unsupported character patterns before querying the remote Bitly API. Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:20-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L20-L28)
```typescript
const invalidBitlyKeyRegex = /[`~,.<>;':"/\\[\]^{}()=+!*@&$ÂŁ?%#|]/;
```
If the key is valid and exists in Bitly's system, the crawler extracts the long URL and persists a new link record asynchronously using `ev.waitUntil`. The newly created link is assigned to a designated Buffer workspace, user, and folder configuration using fixed system IDs. Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:27-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L27-L61)
| Constant Name | Value | Purpose |
| :--- | :--- | :--- |
| `BUFFER_WORKSPACE_ID` | `cm05wnnpo000711ztj05wwdbu` | Workspace ID assigned to auto-ingested Bitly links Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15) |
| `BUFFER_USER_ID` | `cm05wnd49000411ztg2xbup0i` | System user ID associated with ingested links Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L16) |
| `BUFFER_FOLDER_ID` | `fold_1JNQBVZV8P0NA0YGB11W2HHSQ` | Default folder ID for ingested Bitly links Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L17) |
Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15-17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L17)
> [!WARNING]
> If the Bitly API rate limit is exceeded or the link cannot be found, `fetchBitlyLink` returns `null`, causing the crawler to redirect fallback traffic directly to `https://buffer.com` with a 24-hour cache control header. Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:71-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L71-L81)
### Workspace OAuth Integration and Domain Sync
For workspace-level migrations and bulk operations, Bitly integration tokens are exchanged via OAuth and stored securely in Redis. The callback route at `apps/web/app/api/callback/bitly/route.ts` handles token exchange and redirects users back to their workspace slug with query parameters. Sources: [apps/web/app/api/callback/bitly/route.ts:10-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/bitly/route.ts#L10-L59)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Edge-based Link Caching (`linkCache`)** | Sub-millisecond read performance and high availability during traffic spikes Sources: [apps/web/lib/middleware/link.ts:89-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L122) | Potential staleness requiring cache invalidation hooks upon updates Sources: [apps/web/app/ee/api/cron/domains/update/route.ts:103-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts#L103-L104) |
| **Asynchronous Background Ingestion (`ev.waitUntil`)** | Keeps redirect latency minimal while writing back-fill audit logs and metrics Sources: [apps/web/lib/middleware/link.ts:124-147](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L124-L147) | Operations outside the main response thread can fail silently if not wrapped in `Promise.allSettled` |
| **Dedicated System Workspace Constants** | Isolates automated external crawl ingestion from user-created assets Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L18) | Hardcoded IDs require environment synchronization across deployments Sources: [apps/web/lib/middleware/utils/crawl-bitly.ts:15-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L18) |
Sources: [apps/web/lib/middleware/link.ts:89-147](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L147), [apps/web/lib/middleware/utils/crawl-bitly.ts:15-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts#L15-L61), [apps/web/app/api/callback/bitly/route.ts:20-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/bitly/route.ts#L20-L59)
## Related
- [[Routing and Multitenancy]]
- [[A/B Testing and Targeting]]
- [[Tinybird Analytics Engine]]
---
## Technical docs: GET Get events for admin
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getadminevents
## Responses
## Try It
---
## Technical docs: PATCH Review and update fraud alert status
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/updatefraudalertstatus
## Parameters
## Request Body
Fraud alert review status and optional note
## Responses
## Try It
---
## Technical docs: Link Creation and Builder UI
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/link-creation-and-builder-ui
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/ui/modals/link-builder/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page-client.tsx)
- [apps/web/app/ee/api/partners/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts)
- [apps/web/ui/links/link-builder/link-builder-provider.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-provider.tsx)
- [apps/web/lib/api/links/process-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts)
- [packages/ui/src/rich-text-area/link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/rich-text-area/link-modal.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/groups/groupSlug/links/add-edit-group-additional-link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/groups/%5BgroupSlug%5D/links/add-edit-group-additional-link-modal.tsx)
- [apps/web/ui/modals/link-builder/og-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/og-modal.tsx)
- [apps/web/ui/modals/partner-link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/partner-link-modal.tsx)
- [apps/web/ui/links/link-builder/use-link-builder-submit.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/resources/program-brand-assets/add-link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/resources/program-brand-assets/add-link-modal.tsx)
- [apps/web/ui/links/link-builder/link-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/add-edit-link.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/add-edit-link.tsx)
- [apps/web/ui/modals/link-builder/utm-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx)
- [apps/web/ui/modals/add-partner-link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/add-partner-link-modal.tsx)
- [apps/web/ui/links/link-builder/link-builder-header.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-header.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/links/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/page-client.tsx)
- [apps/web/ui/links/links-toolbar.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/links-toolbar.tsx)
- [apps/web/ui/modals/link-builder/targeting-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx)
- [apps/web/ui/modals/link-builder/webhooks-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/webhooks-modal.tsx)
- [apps/web/lib/api/links/create-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/create-link.ts)
- [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx)
- [apps/web/ui/modals/link-builder/partners-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/partners-modal.tsx)
- [apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx)
- [apps/web/app/api/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/route.ts)
- [apps/web/app/api/links/upsert/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts)
- [apps/web/lib/api/links/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/index.ts)
- [apps/web/lib/api/links/case-sensitivity.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts)
- [apps/web/lib/api/links/utils/transform-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/transform-link.ts)
- [apps/web/lib/upstash/assert-rate-limit.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/assert-rate-limit.ts)
## Overview
The Link Creation and Builder UI serves as the core interface and pipeline for generating, managing, and configuring shortened URLs, custom domains, and redirect rules across workspaces. It bridges client-side form management with robust server-side mutation endpoints, enabling users to orchestrate complex link parameters such as Open Graph social previews, UTM tracking strings, geo-device targeting, webhooks, and A/B test variants. By abstracting plan-tier validations, security scans, and case-sensitivity routing behind unified providers and modal layers, the architecture delivers a seamless experience for creating standard, partner-affiliated, and embedded referral links alike.
Sources: [apps/web/ui/modals/link-builder/index.tsx:58-75](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx#L58-L75), [apps/web/ui/links/link-builder/link-builder-provider.tsx:40-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-provider.tsx#L40-L67), [apps/web/lib/api/links/process-link.ts:61-120](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts#L61-L120), [apps/web/lib/api/links/create-link.ts:35-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/create-link.ts#L35-L62)
## Link Builder Architecture and State
The Link Builder architecture is structured around a centralized context provider, React Hook Form integration, and dual presentation modes that span both modal dialogs and dedicated full-page editing routes. At its core, `LinkBuilderProvider` wraps child components with a React Hook Form context (`FormProvider`) and a custom `LinkBuilderContext` that shares builder properties, modal flags, and metatag generation states across input controls and preview surfaces.
Sources: [apps/web/ui/links/link-builder/link-builder-provider.tsx:40-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-provider.tsx#L40-L67)
When the builder initializes, `LinkBuilderProvider` configures form default values using either existing link properties, duplicated link configurations, or fallback defaults defined by `DEFAULT_LINK_PROPS`. Conversion tracking is automatically enabled if the workspace plan qualifies beyond free or pro tiers and conversion tracking is globally allowed.
Sources: [apps/web/ui/links/link-builder/link-builder-provider.tsx:50-58](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-provider.tsx#L50-L58)
> [!NOTE]
> For new link creation flows where neither existing properties nor duplicate properties supply a custom domain, an `useEffect` hook monitors available workspace domains via `useAvailableDomains` and populates the form domain field with the primary domain once loading completes.
Sources: [apps/web/ui/modals/link-builder/index.tsx:117-134](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx#L117-L134)
The link builder renders across different application entry points, including workspace dashboards, link list toolbars, and dedicated URL management views. The architecture bifurcates into two distinct rendering wrappers: modal presentation via `LinkBuilderOuter` and `LinkBuilderInner`, and dedicated page presentation inside `apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx` which renders `LinkBuilderProvider` with `modal={false}` directly inside the dashboard layout, attaching keyboard shortcuts for submission (`CMD+S` / `CTRL+S`) and navigation (`Escape`).
Sources: [apps/web/ui/modals/link-builder/index.tsx:62-184](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx#L62-L184), [apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx:84-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page-client.tsx#L84-L134)
The following table summarizes the primary context properties, hooks, and form management primitives utilized across the link builder ecosystem.
| Context / Hook | Source File | Purpose & Behavior |
| :--- | :--- | :--- |
| `LinkBuilderProvider` | `apps/web/ui/links/link-builder/link-builder-provider.tsx` | Initializes form state with `useForm`, wraps children in `FormProvider` and `LinkBuilderContext`. |
| `useLinkBuilderContext` | `apps/web/ui/links/link-builder/link-builder-provider.tsx` | Consumes builder context, throwing an error if accessed outside `LinkBuilderProvider`. |
| `LinkBuilderHeader` | `apps/web/ui/links/link-builder/link-builder-header.tsx` | Renders folder breadcrumbs, debounce-tracked short link previews, and close controls. |
| `LinkBuilderInner` | `apps/web/ui/modals/link-builder/index.tsx` | Manages modal close actions, URL query param cleanup (`newLink`), and submission redirection. |
| `LinkBuilder` (Page) | `apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx` | Handles desktop/mobile layout splitting, clipboard copying, and keyboard shortcuts (`Escape`, `CMD+S`). |
Sources: [apps/web/ui/links/link-builder/link-builder-provider.tsx:30-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-provider.tsx#L30-L67), [apps/web/ui/links/link-builder/link-builder-header.tsx:24-134](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-builder-header.tsx#L24-L134), [apps/web/ui/modals/link-builder/index.tsx:77-154](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx#L77-L154), [apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx:93-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page-client.tsx#L93-L134)
## Form Submission and Mutation Flow
Link persistence and mutation flow through `useLinkBuilderSubmit`, which constructs payload bodies by normalizing form values—such as mapping tags array to `tagIds`, resolving `"unsorted"` folders to `null`, and handling partner parameters. The request executes against `/api/links` via `POST` for new links or `/api/links/[id]` via `PATCH` for updates.
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:12-58](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L12-L58)
When the form submission triggers, execution proceeds through a structured sequence of payload sanitization, network transmission, response evaluation, and cache invalidation steps:
1. `handleSubmit(onSubmit)` (React Hook Form) → triggers validation and invokes `useLinkBuilderSubmit` callback.
Sources: [apps/web/ui/modals/link-builder/index.tsx:174](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/index.tsx#L174), [apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx:218](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page-client.tsx#L218)
2. Body normalization → maps `tags` to `tagIds`, converts `"unsorted"` folder ID to `null`, clears empty optional fields (`expiredUrl`, `ios`, `android`, `externalId`, `tenantId`), and appends `programId` if `partnerId` is present.
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:23-47](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L23-L47)
3. HTTP Request → dispatches `fetch` using `POST` (`/api/links?workspaceId=...`) or `PATCH` (`/api/links/[id]?workspaceId=...`).
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:49-66](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L49-L66)
4. `res.status === 200` branch → invokes `onSuccess?.(data)`, redirects if domain or key changed, mutates SWR cache prefixes via `mutatePrefix`, copies short links to clipboard for new links, and updates workspace stats via `mutate('/api/workspaces/[slug]')`.
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:68-122](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L68-L122)
5. Error branch (`res.status !== 200`) → parses error message, triggers upsell gating if message includes `"Upgrade to "`, or maps validation errors to specific form fields (`root`, `key`, `url`).
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:123-157](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L123-L157)
Upon successful server response, the client executes cache invalidation across endpoints and handles error fields according to validation rules.
| Target Resource / Field | Action / Mutation Trigger | Error Routing / Condition |
| :--- | :--- | :--- |
| `/api/links` | `mutatePrefix(["/api/links", ...])` | Invalidates all link-related SWR query keys. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:80-84](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L80-L84) |
| `/api/domains` | Appended to `mutatePrefix` if `getValues("key") === "_root"` | Refreshes domain configuration when root domain links are modified. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:82-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L82-L83) |
| Workspace Usage | `mutate('/api/workspaces/[slug]')` | Updates workspace limits and usage metrics after creation or edit. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:121-122](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L121-L122) |
| Image Errors | `setError("root", { message: error.message })` | Triggered when error message includes `"image"` before URL checks. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:146-147](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L146-L147) |
| Key / Short Link Errors | `setError("key", { message: error.message })` | Triggered when error message includes `"key"`. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:148-149](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L148-L149) |
| Destination URL Errors | `setError("url", { message: error.message })` | Triggered when error message includes `"url"`. Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:150-151](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L150-L151) |
> [!WARNING]
> Image validation errors contain the word "URL" in their descriptions but lack a dedicated form field of their own. The error handler explicitly checks for the keyword `"image"` *before* evaluating `"url"` branches to prevent image errors from incorrectly attaching to the destination URL input field instead of form root.
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:141-147](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L141-L147)
When server responses return error payloads containing the substring `"Upgrade to "`, the client intercepts the error message, extracts the target plan name or defaults to workspace next plan name, and renders an `UpgradeRequiredToast` via `toast.custom`.
Sources: [apps/web/ui/links/link-builder/use-link-builder-submit.tsx:126-137](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/use-link-builder-submit.tsx#L126-L137)
## Metatags Previewing and Open Graph
The link builder interface integrates real-time social preview rendering, image manipulation utilities, and an Open Graph (OG) modal override system. Users can cycle through platform-specific previews, customize metadata fields, upload or resize images, and enable proxy-based metatags.
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:44-175](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L44-L175), [apps/web/ui/modals/link-builder/og-modal.tsx:199-215](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/og-modal.tsx#L199-L215)
The `LinkPreview` component reads form values via `useWatch` for `proxy`, `title`, `description`, `image`, `url`, and `password`. It computes a debounced hostname (falling back to `dub.co` if password protection is enabled) and renders a tabbed interface supporting four distinct platforms.
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:75-88](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L75-L88)
| Tab Key | Display Title | Associated Icon | Component Handler |
| :--- | :--- | :--- | :--- |
| `default` | Default | `GlobePointer` | `DefaultOGPreview`. Sources: [apps/web/ui/links/link-builder/link-preview.tsx:47-69](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L47-L69) |
| `x` | X/Twitter | `Twitter` | `XOGPreview`. Sources: [apps/web/ui/links/link-builder/link-preview.tsx:51-70](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L51-L70) |
| `linkedin` | LinkedIn | `LinkedIn` | `LinkedInOGPreview`. Sources: [apps/web/ui/links/link-builder/link-preview.tsx:50-71](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L50-L71) |
| `facebook` | Facebook | `Facebook` | `FacebookOGPreview`. Sources: [apps/web/ui/links/link-builder/link-preview.tsx:49-72](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L49-L72) |
> [!NOTE]
> Pressing the `L` key triggers a registered keyboard shortcut, which immediately opens the Open Graph configuration modal for rapid metadata editing.
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:91](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L91), [apps/web/ui/modals/link-builder/og-modal.tsx:221-235](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/og-modal.tsx#L221-L235)
When users upload or select an image through file upload, Unsplash search, or a direct URL paste, the image undergoes client-side resizing via `resizeImage(file)`. If the workspace is on a paid plan (non-free), updating the image automatically toggles the `proxy` flag to `true` to ensure custom metatags are rendered publicly.
Sources: [apps/web/ui/modals/link-builder/og-modal.tsx:250-321](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/og-modal.tsx#L250-L321), [apps/web/ui/links/link-builder/link-preview.tsx:95-98](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L95-L98)
> [!CAUTION]
> Custom Link Previews and proxy metadata overriding require a Pro plan or above. Free-tier workspaces attempting to toggle the proxy switch encounter a disabled state with an interactive upsell tooltip linking to checkout or upgrade routes.
Sources: [apps/web/ui/links/link-builder/link-preview.tsx:113-133](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/link-preview.tsx#L113-L133)
## Advanced Targeting and Feature Modals
The link builder interface features specialized configuration modals that manage granular targeting options, UTM tracking templates, webhooks, and advanced link identifiers. Each modal synchronizes its internal state with the parent `LinkFormData` context via `react-hook-form`, supporting quick keyboard shortcuts and real-time validation previews.
Sources: [apps/web/ui/modals/link-builder/utm-modal.tsx:1-68](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L1-L68), [apps/web/ui/modals/link-builder/targeting-modal.tsx:1-54](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L1-L54)
The `UTMModal` component renders a dedicated UTM parameter builder alongside a real-time destination URL preview. When users modify UTM fields or load templates via `UTMTemplatesCombo`, the `updateTargeting` callback automatically propagates matching parameters to existing iOS, Android, and geographic targeting URLs.
Sources: [apps/web/ui/modals/link-builder/utm-modal.tsx:33-149](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L33-L149)
The parameter propagation execution walkthrough proceeds as follows: `updateTargeting()` extracts parent destination and targeting URLs → `getParamsFromURL()` parses existing query parameters → `UTM_PARAMETERS.filter()` identifies parameters matching the root destination URL → `constructURLFromUTMParams()` reconstructs target URLs with updated UTM values → `setValueParent()` commits the modified target URLs back to the parent form with `{ shouldDirty: true }`.
Sources: [apps/web/ui/modals/link-builder/utm-modal.tsx:82-147](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L82-L147)
> [!TIP]
> Pressing the `U` key invokes a registered keyboard shortcut that instantly opens the UTM Builder modal from anywhere in the link creation flow.
Sources: [apps/web/ui/modals/link-builder/utm-modal.tsx:2](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L2), [apps/web/ui/modals/link-builder/utm-modal.tsx:178-192](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L178-L192)
The `TargetingModal` component allows redirection based on visitor locations using country comboboxes paired with Vercel flag CDN SVGs, sorting the United States to the top of the option list. Concurrently, the `WebhooksModal` and `WebhookSelect` components integrate workspace webhooks using `useWebhooks()`, rendering multi-select comboboxes with real-time badge counters and keyboard shortcuts mapped to the `W` key.
Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:25-217](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L25-L217), [apps/web/ui/modals/link-builder/webhooks-modal.tsx:17-237](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/webhooks-modal.tsx#L17-L237)
| Modal Component | Shortcut Key | Primary Form Fields | Associated Icon |
| :--- | :--- | :--- | :--- |
| `UTMModal` | `U` | `url`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | `DiamondTurnRight`. Sources: [apps/web/ui/modals/link-builder/utm-modal.tsx:50-58](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L50-L58), [apps/web/ui/modals/link-builder/utm-modal.tsx:179-192](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/utm-modal.tsx#L179-L192) |
| `TargetingModal` | `G` | `ios`, `android`, `geo` | `Crosshairs3`, `Trash`. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:47-53](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L47-L53), [apps/web/ui/modals/link-builder/targeting-modal.tsx:117-130](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L117-L130) |
| `WebhooksModal` | `W` | `webhookIds` | `Webhook`. Sources: [apps/web/ui/modals/link-builder/webhooks-modal.tsx:52-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/webhooks-modal.tsx#L52-L55), [apps/web/ui/modals/link-builder/webhooks-modal.tsx:76-90](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/webhooks-modal.tsx#L76-L90) |
| `AdvancedLinkFeaturesModal` | `V` | `externalId`, `tenantId` | Info/Tooltips. Sources: [apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx:33-37](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx#L33-L37), [apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx:74-89](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx#L74-L89) |
> [!WARNING]
> When updating targeting URLs on blur, `getNewParams()` checks that `parentUrl` contains parameters that are missing on the target URL before injecting them, preventing overwrites of distinct custom query parameters already present on localized links.
Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:68-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L68-L83)
The `AdvancedLinkFeaturesModal` component manages system-level identifiers via `externalId` and `tenantId` fields, bound to the `V` keyboard shortcut. If either identifier is populated on the parent form, a quick-removal toggle button appears at the bottom-left of the form to reset `externalId` back to `null`.
Sources: [apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx:14-153](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/advanced-link-features-modal.tsx#L14-L153)
## API Link Processing and Validation
Link processing and validation take place on the server during link creation via the `POST` route handler, which first evaluates workspace usage limits using `throwIfLinksUsageExceeded(workspace)` and parses request bodies against `createLinkBodySchemaAsync`.
Sources: [apps/web/app/api/links/route.ts:58-64](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/route.ts#L58-L64)
```mermaid
sequenceDiagram
participant route as apps/web/app/api/links/route.ts
participant assertRateLimit as apps/web/lib/upstash/assert-rate-limit.ts
participant formatRetryAfter as apps/web/lib/upstash/assert-rate-limit.ts
route->>assertRateLimit: assertRateLimit({ policy: RATELIMIT_POLICIES.anonymousLinkCreate, identifier: ip })
assertRateLimit->>formatRetryAfter: formatRetryAfter(reset)
formatRetryAfter-->>assertRateLimit: returns human-friendly duration string
assertRateLimit-->>route: throws DubApiError if rate limit exceeded
```
Sources: [apps/web/app/api/links/route.ts:66-72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/route.ts#L66-L72), [apps/web/lib/upstash/assert-rate-limit.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/assert-rate-limit.ts#L28-L34)
For unauthenticated requests where no session exists, the handler extracts the client IP address from the `x-forwarded-for` header (falling back to `LOCALHOST_IP`) and enforces rate limiting by invoking `assertRateLimit`. Inside `assertRateLimit`, if rate limiting is active, a Redis key is constructed by joining `policy.keyPrefix` and the identifier. It calls `ratelimit(policy.attempts, policy.window).limit(key)`. If success is false, `formatRetryAfter` calculates the remaining seconds until reset, formats a human-friendly duration using `pluralize`, evaluates custom policy messages or a default string, and throws a `DubApiError` with code `rate_limit_exceeded`.
Sources: [apps/web/lib/upstash/assert-rate-limit.ts:8-66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/assert-rate-limit.ts#L8-L66)
> [!IMPORTANT]
> When `shouldApplyRateLimit` is disabled in the local environment, `assertRateLimit` immediately returns without performing Redis checks or throwing rate limit errors.
Sources: [apps/web/lib/upstash/assert-rate-limit.ts:35-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/assert-rate-limit.ts#L35-L37)
The payload is subsequently passed to `processLink`, which validates destination URLs and enforces workspace plan constraints. If `url` is provided, it is normalized via `getUrlFromString` and checked with `isValidUrl`; unparseable URLs return an unprocessable entity error with code `unprocessable_entity`. If `url` is absent and the key is not `_root`, processing stops with a bad request error.
Sources: [apps/web/lib/api/links/process-link.ts:74-119](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts#L74-L119)
| Validation Step | Trigger Condition | Error Message / Outcome | Error Code |
| :--- | :--- | :--- | :--- |
| URL Validation | `url` is invalid via `isValidUrl` | `"Invalid destination URL"` | `unprocessable_entity` |
| Missing URL | `!url` and `key !== "_root"` | `"Missing destination URL"` | `bad_request` |
| Root Redirect (Free) | `workspace.plan === "free"`, `key === "_root"`, `url` set | `"You can only set a redirect for a root domain link on a Pro plan and above..."` | `forbidden` |
| Free Subdomain/Features | Free plan boundary checks fail | Error thrown by feature checks | `forbidden` |
| Pro Plan Subdomain | Pro plan boundary checks fail | Error thrown by feature checks | `forbidden` |
| Conversion Tracking | `!trackConversion && testVariants` | `"Conversion tracking must be enabled to use A/B testing."` | `unprocessable_entity` |
| Dub.link Plan Check | `domain === "dub.link"` on free plan | `"You can only use dub.link on a Pro plan and above..."` | `forbidden` |
| Session Expiration | `domain === "dub.sh"`, `userId` provided, user missing | `"Session expired. Please log in again."` | `not_found` |
| Malicious URL | `domain` is `dub.sh` or `dub.link` and URL is malicious | `"Malicious URL detected"` | `unprocessable_entity` |
| Restricted Domain Features | `isDubDomain(domain)` with geo, device, or A/B testing | `"You cannot use geo targeting, device targeting, or A/B testing on ${domain} links."` | `unprocessable_entity` |
| Dub Domain Hostname | URL domain/apex violates Dub domain allowed hostnames | `"Invalid destination URL. You can only create ${domain} short links for URLs with..."` | `unprocessable_entity` |
| Parent Subdirectory | `key` includes `/` but parent link project ID mismatches workspace | `"You do not have access to create links in the ${domain}/${parentKey}/ subdirectory."` | `forbidden` |
| Workspace Ownership | Domain does not belong to workspace domains | `"Domain does not belong to workspace."` | `forbidden` |
Sources: [apps/web/lib/api/links/process-link.ts:94-262](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts#L94-L262)
> [!WARNING]
> For Dub-owned domains like `chatg.pt` or `spti.fi`, usage of geo-targeting, device targeting, and A/B testing is strictly blocked, returning an unprocessable entity error.
Sources: [apps/web/lib/api/links/process-link.ts:207-217](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts#L207-L217)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Centralized `processLink` validation function | Ensures uniform validation rules across API routes and UI actions | Couples disparate feature checks into a large conditional block |
| Upstash Redis rate-limiting per IP/identifier | Protects public endpoints from abuse with low latency overhead | Requires network roundtrips to Redis during unauthenticated creation |
| Strict domain ownership verification via Prisma queries | Prevents unauthorized link creation on custom workspace domains | Adds database query latency on every link creation request |
| Separate free/pro feature check functions | Granular enforcement of tiered subscription entitlements | Requires maintaining multiple feature gate functions across plan types |
Sources: [apps/web/lib/api/links/process-link.ts:132-262](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/process-link.ts#L132-L262), [apps/web/lib/upstash/assert-rate-limit.ts:28-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/upstash/assert-rate-limit.ts#L28-L67)
## Link Persistence and Upsert Pipelines
Link persistence and upsert operations manage the transition from validated payloads to stored database entities, handling case-sensitivity encoding, key transformations, and conditional record updates. When a request hits the upsert pipeline, the system evaluates existing records by workspace and destination URL to determine whether to execute a creation or an update routine.
Sources: [apps/web/app/api/links/upsert/route.ts:22-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts#L22-L33)
When transforming and decoding links, data passes through the verified `PUT` → `transformLink` → `decodeLinkIfCaseSensitive` → `decodeKey` execution chain. The `PUT` upsert route handler (`apps/web/app/api/links/upsert/route.ts`) invokes `transformLink` (`apps/web/lib/api/links/utils/transform-link.ts`), which evaluates case sensitivity and calls `decodeLinkIfCaseSensitive` (`apps/web/lib/api/links/case-sensitivity.ts`). If the link domain is case-sensitive, `decodeLinkIfCaseSensitive` delegates directly to `decodeKey` (`apps/web/lib/api/links/case-sensitivity.ts`) to reverse the base64 encoding and XOR obfuscation applied by `XOR_SECRET_KEY`.
Sources: [apps/web/app/api/links/upsert/route.ts:23-107](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts#L23-L107), [apps/web/lib/api/links/utils/transform-link.ts:35-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/transform-link.ts#L35-L44), [apps/web/lib/api/links/case-sensitivity.ts:30-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L30-L88)
```mermaid
sequenceDiagram
participant PUT as apps/web/app/api/links/upsert/route.ts
participant Transform as apps/web/lib/api/links/utils/transform-link.ts
participant CaseSens as apps/web/lib/api/links/case-sensitivity.ts
participant Decode as decodeKey
PUT->>Transform: transformLink(link)
Transform->>CaseSens: decodeLinkIfCaseSensitive(link)
CaseSens->>Decode: decodeKey(link.key)
Decode-->>CaseSens: originalKey
CaseSens-->>Transform: decoded link object
Transform-->>PUT: fully transformed response
```
Sources: [apps/web/app/api/links/upsert/route.ts:23-107](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts#L23-L107), [apps/web/lib/api/links/utils/transform-link.ts:35-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/utils/transform-link.ts#L35-L44), [apps/web/lib/api/links/case-sensitivity.ts:30-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L30-L88)
Case-sensitive domains require key obfuscation because underlying storage or routing layers may normalize character casing. The system maintains an explicit array of case-sensitive domains and uses a fixed XOR secret string combined with base64 encoding to persist keys securely while retaining exact casing upon retrieval.
Sources: [apps/web/lib/api/links/case-sensitivity.ts:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L1-L28)
| Domain Identifier | Status | Purpose / Behavior |
| :--- | :--- | :--- |
| `biltapp.link` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L4-L5) |
| `buff.ly` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:6](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L6) |
| `dub-internal-test.com` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L7) |
| `go.homeserve.fr` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L8) |
| `go.homeserve.be` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:9](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L9) |
| `jbbr.pro` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:10](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L10) |
| `new.biltapp.link` | Case-Sensitive | Encodes and decodes short keys via XOR and base64. Sources: [apps/web/lib/api/links/case-sensitivity.ts:11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/case-sensitivity.ts#L11) |
> [!IMPORTANT]
> When evaluating whether key checks can be skipped during upsert operations, the system compares lowercased keys alongside domain matching to prevent redundant conflict queries when only casing is adjusted on non-sensitive domains.
Sources: [apps/web/app/api/links/upsert/route.ts:118-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts#L118-L121)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Deep equality check prior to mutations | Avoids unnecessary database writes and webhook triggers when payloads match | Computes deep object comparisons on existing database entities |
| Conditional skip flags (`skipKeyChecks`, `skipExternalIdChecks`) | Streamlines update execution by bypassing redundant uniqueness validation | Requires explicit boolean state coordination inside the upsert handler |
| Asynchronous background sync via `waitUntil` | Keeps HTTP response latency low for upsert and creation APIs | Defers cache updates, webhook publishing, and Tinybird event recording to post-response background tasks |
Sources: [apps/web/app/api/links/upsert/route.ts:81-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/upsert/route.ts#L81-L165), [apps/web/lib/api/links/create-link.ts:164-245](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/create-link.ts#L164-L245)
## Related
- [[A/B Testing and Targeting]]
- [[QR Code Generation]]
---
## Technical docs: POST Impersonate a user or workspace
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/adminimpersonateuser
## Request Body
Identifier to search for user, workspace, domain, or Stripe customer ID
## Responses
## Try It
---
## Technical docs: A/B Testing and Targeting
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/a-b-testing-and-targeting
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/lib/middleware/utils/resolve-ab-test-url.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts)
- [apps/web/ui/modals/link-builder/targeting-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/app/api/domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/api/links/random/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/random/route.ts)
- [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx)
- [apps/web/ui/modals/link-builder/ab-testing-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/app/ee/api/cron/domains/update/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts)
- [apps/web/ui/links/link-tests.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx)
- [apps/web/app/api/providers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/providers/route.ts)
- [apps/web/app/api/links/iframeable/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/iframeable/route.ts)
- [apps/web/app/api/links/metatags/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/metatags/route.ts)
- [apps/web/lib/api/links/complete-ab-tests.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts)
- [packages/utils/src/constants/dub-domains.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/dub-domains.ts)
- [apps/web/app/app.dub.co/deeplink/deeplink/domain/...key/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(deeplink)/deeplink/%5Bdomain%5D/%5B%5B...key%5D%5D/page.tsx)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts)
- [apps/web/lib/webhook/sample-events/link-clicked.json](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/sample-events/link-clicked.json)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/crawl-bitly.ts)
- [apps/web/ui/partners/program-link-configuration.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/program-link-configuration.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/default-domain-selector.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx)
- [apps/web/ui/placeholders/feature-graphics/domains.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/feature-graphics/domains.tsx)
- [apps/web/ui/links/tests-badge.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/tests-badge.tsx)
- [apps/web/lib/middleware/create-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/create-link.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/products/product-selector.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/products/product-selector.tsx)
- [apps/web/app/domain/key/stats/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/%5Bkey%5D/stats/page.tsx)
## Overview
A/B testing and targeting empower you to optimize short link destinations by routing incoming traffic across multiple weighted destination URLs or segmenting users based on geographic location and device type. This system enables data-driven link optimization, automated experiment lifecycles, and granular conversion tracking directly within your link management workflow. Sources: [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:244-246](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L244-L246), [apps/web/lib/middleware/link.ts:150-174](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L150-L174)
By combining edge-level request evaluation with flexible modal controls and real-time analytics badging, the platform seamlessly handles traffic splitting, parameter inheritance, and automatic winner selection once an experiment concludes. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:8-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L8-L36), [apps/web/lib/api/links/complete-ab-tests.ts:12-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L12-L32), [apps/web/ui/links/link-tests.tsx:11-121](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L11-L121)
## Edge Link Routing and Evaluation
### Overview
Incoming short link requests are intercepted by Next.js middleware at the edge and routed through `LinkMiddleware`, where cached link metadata, targeting rules, and A/B test variants are evaluated. The execution flow begins when the request enters `middleware()` in `apps/web/middleware.ts`, which parses the incoming request to extract the domain, path, and key. Sources: [apps/web/lib/middleware/link.ts:43-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L43-L44), [apps/web/middleware.ts:34-35](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L35)
### Execution Call-Chain
The evaluation flow follows a deterministic sequence of helper functions and cache checks before resolving the final destination URL:
1. `parse(req)` extracts domain, fullKey, and search parameters from the request. Sources: [apps/web/lib/middleware/link.ts:44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L44)
2. `punyEncode(originalKey)` and domain case-sensitivity checks normalize the link key. Sources: [apps/web/lib/middleware/link.ts:52-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L52-L56)
3. `linkCache.get({ domain, key })` retrieves cached link properties or triggers a fallback via `getLinkViaEdge({ domain, key })` if the cache misses. Sources: [apps/web/lib/middleware/link.ts:89-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L89-L104)
4. Extracted link properties (`testVariants`, `testCompletedAt`) are passed directly into `resolveABTestURL()`. Sources: [apps/web/lib/middleware/link.ts:150-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L150-L171)
5. The resolved `testUrl` overrides the default `cachedLink.url` when active test variants are present. Sources: [apps/web/lib/middleware/link.ts:173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L173)
Sources: [apps/web/lib/middleware/link.ts:44-173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L44-L173)
> [!NOTE]
> During Redis failover events (`redisFailOver === true`), click tracking jobs are bypassed to prevent request timeouts, and cookie minting for conversion tracking is suspended. Sources: [apps/web/lib/middleware/link.ts:93-98](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L93-L98), [apps/web/lib/middleware/link.ts:193](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts#L193)
### Middleware Routing Constants
The edge middleware matches requests against specific hostnames and path prefixes before invoking link resolution logic.
| Hostname or Prefix | Handler Function / Action | Target / Purpose | Sources |
| :--- | :--- | :--- | :--- |
| `isAppHostname(domain)` | `AppMiddleware(req)` | Handles requests for `app.dub.co` | [apps/web/middleware.ts:42-44](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L42-L44) |
| `API_HOSTNAMES.has(domain)` | `ApiMiddleware(req)` | Handles public API requests | [apps/web/middleware.ts:47-49](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L47-L49) |
| `path.startsWith("/stats/")` | `NextResponse.rewrite(...)` | Rewrites public stats page requests | [apps/web/middleware.ts:52-59](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L52-L59) |
| `path.startsWith("/.well-known/")` | `NextResponse.rewrite(...)` | Serves verified well-known configuration files | [apps/web/middleware.ts:61-69](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L61-L69) |
| `ADMIN_HOSTNAMES.has(domain)` | `AdminMiddleware(req)` | Handles administrative dashboard routes | [apps/web/middleware.ts:76-78](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L76-L78) |
| `PARTNERS_HOSTNAMES.has(domain)` | `PartnersMiddleware(req)` | Handles partner program portal requests | [apps/web/middleware.ts:80-82](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L80-L82) |
| `isValidUrl(fullKey)` | `CreateLinkMiddleware(req)` | Handles direct short link creation shortcuts | [apps/web/middleware.ts:84-86](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L84-L86) |
Sources: [apps/web/middleware.ts:41-86](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L41-L86)
## A/B Test Variant Resolution
### Overview
The A/B testing destination selection is handled by `resolveABTestURL`, an asynchronous utility function that performs either cookie-driven sticky routing or weighted random percentage traffic splitting across configured test variant URLs. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:6-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L6-L14)
### Execution Call-Chain
The resolution process evaluates preconditions, checks client cookies, computes cumulative distribution weights, and selects a destination URL through a structured sequence of steps:
1. Initial validation checks that `testVariants` and `testCompletedAt` are supplied, and that `testCompletedAt` is set to a future date (`new Date(testCompletedAt) > new Date()`). If any check fails, the function returns `null`. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:16-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L16-L22)
2. Array boundary validation confirms that `testVariants.length` is between 2 and `MAX_TEST_COUNT`. If out of bounds, an error is logged via `console.error` and `null` is returned. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:24-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L24-L27)
3. Cookie retrieval checks `cookieStore.get("dub_test_url")?.value`. If a cookie exists and its value matches one of the valid variant URLs in `testVariants`, sticky routing returns `urlFromCookie` immediately, bypassing random assignment. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:29-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L29-L36)
4. Cumulative weights generation iterates through `testVariants` starting at index 1, adding each variant's percentage to the preceding cumulative sum in `weights`. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:38-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L38-L44)
5. Weighted random selection generates a random number between `0` and the total cumulative weight (`weights[weights.length - 1]`) using `Math.random()`. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:46-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L46-L47)
6. Variant matching loops through `weights`, finding the first index where `weights[i] > random`, logs the selected variant via `console.log`, and returns `testVariants[i].url`. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:49-58](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L49-L58)
Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:8-64](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L8-L64)
> [!WARNING]
> If `testCompletedAt` is in the past or omitted, `resolveABTestURL` immediately returns `null`, short-circuiting active test variant evaluation even if variants are populated. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:16-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L16-L22)
> [!NOTE]
> Sticky session persistence relies entirely on the `dub_test_url` cookie value. If a user clears cookies or visits from a new browser session, they will be re-allocated randomly according to the percentage traffic weights. Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:29-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L29-L36)
### Resolution Constants and Schema Parameters
| Parameter / Constant | Source Definition | Purpose / Constraint | Sources |
| :--- | :--- | :--- | :--- |
| `MAX_TEST_COUNT` | `ABTestVariantsSchema` import | Enforces upper bound limit on allowable test variants per link | [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:1](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L1), [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:24-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L24-L27) |
| `dub_test_url` | `cookieStore.get("dub_test_url")` | Cookie name storing the user's sticky test destination URL | [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L30) |
| Min test variants | `testVariants.length < 2` | Requires at least two variants to perform an A/B test split | [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:24-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L24-L27) |
Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:1-64](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L1-L64)
## Targeting Rules and UTM Inheritance
### Overview
The targeting modal UI configures geographic (`geo`) and device-specific (`ios`, `android`) redirection rules for shortened links. It includes parameter propagation logic that inspects the parent URL's UTM parameters on blur events and automatically appends missing parameters to the target URL. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:67-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L67-L83)
### UTM Parameter Propagation Call Chain
When a user finishes editing a targeting URL input and triggers a `blur` event, the application executes a specific sequence of utility functions to propagate missing marketing parameters.
1. `getNewParams(targetURL)`: Validates that `targetURL` is non-empty and well-formed via `isValidUrl(targetURL)`, retrieves parent URL parameters using `getParamsFromURL(parentUrl)`, and inspects target parameters via `getParamsFromURL(targetURL)`. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:68-74](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L68-L74)
2. `UTM_PARAMETERS.filter(...)`: Iterates over defined UTM keys, checking whether `parentParams?.[key]` exists while `!targetParams?.[key]` is true. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:76-77](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L76-L77)
3. `map(...)` and `Object.fromEntries(...)`: Transforms filtered key-value pairs into an object dictionary of missing parameters, returning `null` if no parameters require propagation. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:78-80](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L78-L80)
4. `constructURLFromUTMParams(value, newParams)`: Appends the resolved `newParams` dictionary to the target URL if `newParams` evaluates to a non-null object during input blur handling for iOS, Android, or geographic targets. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:220-235](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L220-L235), [apps/web/ui/modals/link-builder/targeting-modal.tsx:287-296](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L287-L296), [apps/web/ui/modals/link-builder/targeting-modal.tsx:319-328](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L319-L328)
Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:67-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L67-L83), [apps/web/ui/modals/link-builder/targeting-modal.tsx:220-328](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L220-L328)
> [!TIP]
> Parameter inheritance is deferred until the input loses focus (`onBlur`). This allows users to freely type or paste target URLs without interference from automatic query parameter injection. Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:67-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L67-L83)
### Targeting Fields and Actions Reference
| Field / Action | Form Identifier / Register | Behavior / Constraint | Sources |
| :--- | :--- | :--- | :--- |
| Geographic Targeting | `geo` | Dynamic key-value pairs mapping location codes to destination URLs with blur inheritance and deletion handlers | [apps/web/ui/modals/link-builder/targeting-modal.tsx:204-248](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L204-L248) |
| Add Location Button | `setValue("geo", ...)` | Appends an empty key-value pair (`{ "": "" }`) to `geo`; disabled when an empty key already exists | [apps/web/ui/modals/link-builder/targeting-modal.tsx:253-266](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L253-L266) |
| iOS Targeting | `ios` | Device-specific redirect input registered with blur-triggered UTM parameter propagation | [apps/web/ui/modals/link-builder/targeting-modal.tsx:282-298](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L282-L298) |
| Android Targeting | `android` | Device-specific redirect input registered with blur-triggered UTM parameter propagation | [apps/web/ui/modals/link-builder/targeting-modal.tsx:314-330](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L314-L330) |
| Remove Targeting | `parentEnabled` check | Resets `ios`, `android`, and `geo` parent values to `null` and closes the modal | [apps/web/ui/modals/link-builder/targeting-modal.tsx:337-350](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L337-L350) |
Sources: [apps/web/ui/modals/link-builder/targeting-modal.tsx:204-350](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/targeting-modal.tsx#L204-L350)
## Test Variant Builder and Allocation
### Overview
The A/B testing link builder modal provides UI controls for configuring test destination variants, adjusting percentage traffic allocations, and specifying test completion timelines. The modal is encapsulated within `ABTestingModal`, rendering `ABTestingModalInner` which conditionally switches between `ABTestingComplete` and `ABTestingEdit` depending on whether an existing test's completion date (`testCompletedAt`) has passed. Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:49-86](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L49-L86), [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:194-200](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L194-L200)
### Variant Allocation and Mutation Logic
When editing test variants inside `ABTestingEdit`, users can add or remove variant URLs up to configured limits. The allocation workflow handles uniform distribution and unequal splitting using specific state update routines.
1. `addTestUrl()`: Validates that `testVariants.length` is less than `MAX_TEST_COUNT`. If all existing variants have equal percentage shares (`allEqual`), it recalculates new equal shares using `Math.floor(100 / (testVariants.length + 1))` and assigns the remainder to the new entry. If percentages are unequal, it locates the last variant with a percentage greater than or equal to `MIN_TEST_PERCENTAGE * 2` (`toSplitIndex`), halves its percentage, and assigns the split remainder to the new variant URL. Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:143-186](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L143-L186)
2. `removeTestUrl(index)`: Requires at least 2 variants (`testVariants.length < 2` aborts). If percentages are equal, it reallocates equal shares across the remaining count. If unequal, it filters out the target index and adds the removed variant's percentage (`remainder`) onto the final item in the array. Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:188-233](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L188-L233)
3. `TrafficSplitSlider`: Passes the active `testVariants` array and an `onChange` callback that iterates over updated percentage values, updating each variant via `setValue(..., { shouldDirty: true })`. Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:353-362](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L353-L362)
Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:143-233](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L143-L233), [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:353-362](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L353-L362)
> [!WARNING]
> Submitting the form triggers strict validation rules: if the variant count drops to one or zero, `testVariants` and `testCompletedAt` are reset to `null`. Otherwise, the form validates that `totalPercentage` strictly equals `100` and that every variant contains a non-empty `url` string before saving changes to parent form state. Sources: [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:207-232](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L207-L232)
### A/B Testing Modal Configuration Reference
| Component / Function | Register / Identifier | Purpose and Behavior | Sources |
| :--- | :--- | :--- | :--- |
| `ABTestingModal` | Root container | Renders the modal dialog with class `sm:max-w-md` based on `showABTestingModal` boolean state | [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:49-65](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L49-L65) |
| `ABTestingModalInner` | Form context watch | Inspects `testVariants` and `testCompletedAt` to render either completion status or editing view | [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:67-86](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L67-L86) |
| Testing URL Input | `testVariants.{index}.url` | URL input validated via `isValidUrl` and checked against `MAX_TEST_COUNT` constraints | [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:287-306](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L287-L306) |
| Traffic Splitter | `testVariants.{index}.percentage` | Visual slider component adjusting relative traffic allocation per destination URL | [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:353-362](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L353-L362) |
Sources: [apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx:49-86](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing/ab-testing-modal.tsx#L49-L86), [apps/web/ui/modals/link-builder/ab-testing-modal.tsx:287-362](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-builder/ab-testing-modal.tsx#L287-L362)
## Experiment Lifecycle and Completion
### Overview
The experiment completion subsystem evaluates performance metrics for concluded A/B tests, determines winning destination URLs based on lead conversion counts, updates persistent database records, and dispatches asynchronous lifecycle events. This flow is orchestrated by the `completeABTests` utility function. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:12-90](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L12-L90)
### Call-Chain Execution Walkthrough
When an A/B test concludes, `completeABTests(link)` executes a sequential pipeline to evaluate analytics, resolve a winner, and persist state changes:
1. Guard validation checks that `link.testVariants`, `link.testCompletedAt`, and `link.projectId` are all present; otherwise, execution returns early. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:12-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L12-L15)
2. `ABTestVariantsSchema.parse(link.testVariants)` validates and types the variant configuration array. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L17)
3. `getAnalytics()` queries lead counts grouped by `top_base_urls` for the specific `linkId` and workspace (`link.projectId`), bounded between `link.testStartedAt` and `link.testCompletedAt`. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:19-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L19-L26)
4. `Math.max()` computes the peak lead count across all test variants. If `max === 0`, execution halts and logs that all results are zero. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:28-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L28-L40)
5. `testVariants.filter()` selects all variants matching the maximum lead count. If `winners.length === 0`, execution aborts. If multiple variants share the maximum lead count, `Math.floor(Math.random() * winners.length)` breaks ties uniformly at random. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:42-55](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L42-L55)
6. If `winner.url === link.url`, the process terminates. Otherwise, `prisma.link.update()` updates the destination `url` to the winner's URL while including tags, program enrollments, and project relations. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:57-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L57-L73)
7. `waitUntil()` schedules background settlement via `Promise.allSettled`, which executes `linkCache.set(response)`, `recordLink(response)`, and `sendWorkspaceWebhook()` with trigger `link.updated`. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:75-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L75-L89)
Sources: [apps/web/lib/api/links/complete-ab-tests.ts:12-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L12-L89)
> [!CAUTION]
> If multiple variants tie for the highest lead count, `completeABTests` uses `Math.floor(Math.random() * winners.length)` to select a winner uniformly at random among the top performers. If all variants record zero leads (`max === 0`), the test completes without mutating the link URL or changing its state. Sources: [apps/web/lib/api/links/complete-ab-tests.ts:28-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L28-L56)
### Lifecycle Completion API Reference
| Function / Utility | Parameter / Input | Operation and Output | Sources |
| :--- | :--- | :--- | :--- |
| `completeABTests` | `link: Link` | Validates completion flags, fetches analytics, computes winner, updates database, and triggers background webhooks | [apps/web/lib/api/links/complete-ab-tests.ts:12-90](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L12-L90) |
| `ABTestVariantsSchema` | `link.testVariants` | Zod schema parsing variant configuration payloads | [apps/web/lib/api/links/complete-ab-tests.ts:5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L5), [apps/web/lib/api/links/complete-ab-tests.ts:17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L17) |
| `getAnalytics` | Query configuration object | Fetches lead counts grouped by `top_base_urls` for the experiment window | [apps/web/lib/api/links/complete-ab-tests.ts:19-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L19-L26) |
| `prisma.link.update` | `{ where: { id }, data: { url }, include }` | Persists the winning variant URL and fetches associated tags, program enrollments, and project relations | [apps/web/lib/api/links/complete-ab-tests.ts:61-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L61-L73) |
| `waitUntil` | `Promise.allSettled([...])` | Vercel functions helper ensuring asynchronous cache updates, Tinybird recording, and webhook dispatch complete safely | [apps/web/lib/api/links/complete-ab-tests.ts:7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L7), [apps/web/lib/api/links/complete-ab-tests.ts:75-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L75-L89) |
Sources: [apps/web/lib/api/links/complete-ab-tests.ts:1-90](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/links/complete-ab-tests.ts#L1-L90)
## Link Dashboard Analytics and Badging
### Overview
Link management interfaces expose active split test states and conversion analytics directly via companion badging components and expandable UI rows. The `TestsBadge` component renders a hover card and a toggle button adorned with a `Flask` icon, allowing operators to reveal or hide active split testing configurations from the link card context. When expanded, `LinkTests` inspects the link's `testVariants` and verifies that `testCompletedAt` exists and lies in the future (`new Date() < new Date(link.testCompletedAt)`). Valid variant structures are parsed via `ABTestVariantsSchema`.
Sources: [apps/web/ui/links/link-tests.tsx:1-29](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L1-L29), [apps/web/ui/links/tests-badge.tsx:1-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/tests-badge.tsx#L1-L44)
### Analytics Retrieval and Metric Rendering
Once active variants are confirmed and test visibility is enabled (`showTests` is true), `LinkTests` queries the `/api/analytics` endpoint using SWR with `revalidateOnFocus: false`. The request constructs composite parameters grouped by `top_base_urls` for the specific `linkId` and `workspaceId`, optionally bounding the start time to `link.testStartedAt`.
```typescript
const { data, isLoading, error } = useSWR<
{
url: string;
clicks: number;
leads: number;
saleAmount: number;
sales: number;
}[]
>(
Boolean(testVariants && testVariants.length) &&
showTests &&
`/api/analytics?${new URLSearchParams({
event: "composite",
groupBy: "top_base_urls",
linkId: link.id,
workspaceId: workspaceId!,
...(link.testStartedAt && {
start: new Date(link.testStartedAt).toISOString(),
}),
}).toString()}`,
fetcher,
{
revalidateOnFocus: false,
},
);
```
Sources: [apps/web/ui/links/link-tests.tsx:31-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L31-L55)
For each test variant iteration, the UI matches analytics data against the variant's destination URL and renders a numbered indicator, a pretty-printed URL via `getPrettyUrl()`, a rounded percentage badge representing the traffic allocation (`Math.round(test.percentage)%`), and a `LinkAnalyticsBadge` component populated with aggregated clicks, leads, sales, and sale amounts.
Sources: [apps/web/ui/links/link-tests.tsx:57-117](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L57-L117)
> [!NOTE]
> The analytics fetch within `LinkTests` is guarded by both `Boolean(testVariants && testVariants.length)` and `showTests`. If testing visibility is toggled off or variants are absent, SWR request dispatching is skipped entirely to conserve analytics query quota. Sources: [apps/web/ui/links/link-tests.tsx:31-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L31-L55)
### Component and UI Contract Reference
| Component / Utility | File Path | Primary Function and Behavior | Sources |
| :--- | :--- | :--- | :--- |
| `TestsBadge` | `apps/web/ui/links/tests-badge.tsx` | Renders a Radix hover card and interactive button with a `Flask` icon to toggle test visibility state (`showTests`). | [apps/web/ui/links/tests-badge.tsx:9-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/tests-badge.tsx#L9-L44) |
| `LinkTests` | `apps/web/ui/links/link-tests.tsx` | Validates completion dates, parses variant schemas, and animates height expansion (`motion.div`). | [apps/web/ui/links/link-tests.tsx:11-121](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L11-L121) |
| `ABTestVariantsSchema` | `apps/web/zod/schemas/links.ts` | Zod schema used to parse and validate link `testVariants` payloads. | [apps/web/ui/links/link-tests.tsx:2](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L2) |
| `LinkAnalyticsBadge` | `apps/web/ui/links/link-analytics-badge.tsx` | Renders individual variant performance metrics including clicks, leads, sales, and revenue amounts. | [apps/web/ui/links/link-tests.tsx:102-109](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L102-L109) |
Sources: [apps/web/ui/links/link-tests.tsx:1-121](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-tests.tsx#L1-L121), [apps/web/ui/links/tests-badge.tsx:1-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/tests-badge.tsx#L1-L44)
## Related
- [[Link Creation and Builder UI]]
- [[Link Resolution and Redirection]]
---
## Technical docs: DELETE Ban a link by domain and key
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/adminbanlink
## Parameters
## Responses
## Try It
---
## Technical docs: QR Code Generation
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/qr-code-generation
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/qr/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx)
- [apps/web/lib/qr/utils.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx)
- [apps/web/app/api/qr/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx)
- [apps/web/lib/qr/api.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/api.tsx)
- [apps/web/ui/modals/qr-code-design-fields.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx)
- [apps/web/ui/placeholders/feature-graphics/qr.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/feature-graphics/qr.tsx)
- [apps/web/ui/shared/qr-code.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/shared/qr-code.tsx)
- [apps/web/app/api/og/partner-rewind/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/og/partner-rewind/route.tsx)
- [apps/web/lib/qr/codegen.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts)
- [apps/web/ui/partners/groups/design/previews/portal-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/groups/design/previews/portal-preview.tsx)
- [apps/web/ui/links/link-builder/qr-code-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/qr-code-preview.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/ui/partners/groups/design/previews/embed-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/groups/design/previews/embed-preview.tsx)
- [apps/web/app/api/og/avatar/...seed/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/og/avatar/%5B%5B...seed%5D%5D/route.tsx)
- [apps/web/ui/shared/icons/qr.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/shared/icons/qr.tsx)
- [apps/web/app/api/og/program/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/og/program/route.tsx)
- [apps/web/ui/modals/link-qr-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx)
- [apps/web/lib/openapi/qr/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/qr/index.ts)
- [apps/web/ui/modals/partner-link-qr-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/partner-link-qr-modal.tsx)
- [packages/ui/src/icons/nucleo/qrcode.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/icons/nucleo/qrcode.tsx)
- [apps/web/lib/qr/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/constants.ts)
- [apps/web/lib/zod/schemas/qr.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/qr.ts)
## Overview
QR code generation powers high-engagement branding and physical-to-digital distribution across the platform by transforming short links into customizable, scannable matrix graphics. It bridges low-level error correction algorithms and vector rendering pipelines with user-facing interface controls, enabling both automated edge API generation and rich client-side design customization.
Sources: [apps/web/lib/qr/codegen.ts:17-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L17-L31), [apps/web/app/api/qr/route.tsx:18-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L18-L58), [apps/web/ui/modals/qr-code-design-fields.tsx:55-79](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L55-L79)
## Codegen and Error Correction Engine
### Overview
Low-level QR code generation coordinates version selection, bitstream construction, byte packing, Reed-Solomon error correction, and matrix symbol layout. The engine handles inputs through high-level text or binary factories (`QrCode.encodeText()`, `QrCode.encodeBinary()`) and mid-level segment builders (`QrCode.encodeSegments()`) before instantiating the immutable symbol layout.
Sources: [apps/web/lib/qr/codegen.ts:24-55](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L24-L55)
### Segment Encoding and Version Selection Walkthrough
The encoding pipeline executes a precise multi-step procedure to determine symbol dimensions, pack payloads, and format raw codewords:
1. `QrCode.encodeSegments()` iterates through version numbers starting from `minVersion` (default 1) up to `maxVersion` (40).
2. For each version, it computes `dataCapacityBits` (`QrCode.getNumDataCodewords(version, ecl) * 8`) and compares it against `QrSegment.getTotalBits(segs, version)`.
3. Once a version satisfies capacity requirements, it evaluates `boostEcl` across error correction levels (`LOW`, `MEDIUM`, `QUARTILE`, `HIGH`) to upgrade correction strength if space permits without increasing version size.
4. It concatenates segment mode bits (4 bits), character count bits, and segment payloads into a bit array `bb`.
5. It appends up to 4 terminator bits and zero-pads to a byte boundary, then fills remaining capacity up to `dataCapacityBits` with alternating pad bytes (`0xec` and `0x11`).
6. It packs the bit stream into big-endian byte arrays (`dataCodewords`) and invokes the low-level constructor `new QrCode(version, ecl, dataCodewords, mask)`.
Sources: [apps/web/lib/qr/codegen.ts:68-151](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L68-L151)
> [!NOTE]
> When `boostEcl` is enabled, the engine actively promotes the error correction level if the data payload fits comfortably within the chosen version at a higher redundancy tier.
Sources: [apps/web/lib/qr/codegen.ts:59-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L59-L75), [apps/web/lib/qr/codegen.ts:103-115](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L103-L115)
### Error Level Mapping and Constants
Constants and configuration mappings establish error correction lookup keys, canvas scale factors, and rendering defaults used across the QR pipeline.
| Constant Name | Value / Mapping | Purpose |
| --- | --- | --- |
| `ERROR_LEVEL_MAP` | `L` → `Ecc.LOW`, `M` → `Ecc.MEDIUM`, `Q` → `Ecc.QUARTILE`, `H` → `Ecc.HIGH` | Maps string codes to internal error correction enums |
| `DEFAULT_SIZE` | `128` | Default output dimension fallback in pixels |
| `DEFAULT_LEVEL` | `"L"` | Default error correction level specifier |
| `DEFAULT_BGCOLOR` | `"#FFFFFF"` | Default background color hex code |
| `DEFAULT_FGCOLOR` | `"#000000"` | Default foreground module color hex code |
| `DEFAULT_MARGIN` | `2` | Default quiet zone module width |
| `QR_LEVELS` | `["L", "M", "Q", "H"]` | Array of supported error correction level identifiers |
| `DEFAULT_IMG_SCALE` | `0.1` | Rough scale estimate for maximum allowed coverage |
Sources: [apps/web/lib/qr/constants.ts:3-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/constants.ts#L3-L22)
> [!WARNING]
> `DEFAULT_IMG_SCALE` uses a rough area estimate for maximum logo coverage. When integrating images, ensure dimensions do not exceed scannability limits despite fallback thresholds.
Sources: [apps/web/lib/qr/constants.ts:18-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/constants.ts#L18-L22)
### Codegen Design Trade-Offs
| Design Choice | Benefit | Cost |
| --- | --- | --- |
| Iterative version scanning (`minVersion` to `maxVersion`) | Automatically selects the smallest valid QR version for any payload | Increases CPU overhead for dynamic sizing on large segment arrays |
| Immutable module grid structure (`readonly size`, private `modules`) | Guarantees thread safety and prevents unintended mutation post-construction | Allocates new 2D boolean arrays per generated code symbol |
| Automatic mask evaluation (`mask = -1`) | Tests all 8 mask patterns to minimize penalty scores | Computationally intensive step during matrix finalization |
Sources: [apps/web/lib/qr/codegen.ts:17-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L17-L31), [apps/web/lib/qr/codegen.ts:68-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L68-L75), [apps/web/lib/qr/codegen.ts:87-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L87-L101), [apps/web/lib/qr/codegen.ts:164-167](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/codegen.ts#L164-L167)
## Geometry and Path Construction
### Overview
The geometry and path construction subsystem transforms raw module arrays generated by the QR engine into optimized SVG paths, customized dot configurations, and structured finder patterns. Instead of rendering individual DOM nodes for every dark module — which scales poorly for high-density symbols like version 40 — the renderer constructs unified path strings using efficient string concatenation and geometric helpers.
Sources: [apps/web/lib/qr/index.tsx:362-368](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L362-L368), [apps/web/lib/qr/api.tsx:60-66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/api.tsx#L60-L66)
### Finder Pattern Construction and Compound Paths
Finder patterns (the 7Ă—7 alignment and positioning markers located at the three corners of the matrix) are constructed using dedicated SVG path builders and compound geometries. The `finderBorderPath` helper generates outer boundary rings and inner cutouts based on the selected `borderStyle`.
| Border Style Name | Path Implementation Strategy | Corner Radius / Dimensions |
| --- | --- | --- |
| `square` | Sharp-cornered outer rectangle with a nested inner rectangular cutout | Outer: 7Ă—7 at `(x, y)`, Inner: 4Ă—4 at `(x+1, y+1)` |
| `rounded-square` | Rounded rectangle paths using `roundedRectPath` with continuous arc commands | Outer radius: `1.5`, Inner radius: `0.75` |
| `circle` | Concentric circular paths using half-arc SVG commands via `circlePath` | Outer radius: `3.5`, Inner center offset: `cx = x + 3.5`, `cy = y + 3.5` |
Sources: [apps/web/lib/qr/utils.tsx:343-403](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L343-L403)
> [!NOTE]
> Finder border paths use the `evenodd` fill rule (`fillRule="evenodd"`), allowing the central ring gap to remain transparent without requiring a separate background-colored blocking rectangle. This prevents unwanted hard-cornered white squares from appearing under rounded or circular marker styles.
Sources: [apps/web/lib/qr/utils.tsx:378-382](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L378-L382), [apps/web/lib/qr/utils.tsx:434-434](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L434-L434)
### Finder Position Mapping Walkthrough
The layout engine determines the exact coordinates for the three standard finder patterns relative to the module matrix and margin offset:
1. It reads the total module count from `cells.length`.
2. It constructs an array of three finder positions (`finderPositions`) combining the module dimensions, quiet zone `margin`, and 7Ă—7 finder dimensions:
- Top-left: `{ x: margin, y: margin }`
- Top-right: `{ x: numModules - 7 + margin, y: margin }`
- Bottom-left: `{ x: margin, y: numModules - 7 + margin }`
3. It maps each position through `getFinderPatternSVGString` (or the `FinderPattern` component) supplying the computed `effectiveMarkerColor`, `bgColor`, `borderStyle`, and `centerStyle`.
4. It joins the resulting SVG strings into the final markup payload.
Sources: [apps/web/lib/qr/index.tsx:369-387](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L369-L387), [apps/web/lib/qr/utils.tsx:560-566](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L560-L566)
## Canvas and SVG Rendering Pipelines
### Overview
The canvas and SVG rendering pipelines handle the transformation of encoded module matrices into final, renderable visual outputs across raster HTML5 canvases and vector SVG documents. These pipelines manage device pixel ratios, image loading lifecycles, logo embedding, and module excavation to prevent obscured data codewords.
Sources: [apps/web/lib/qr/index.tsx:315-399](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L315-L399), [apps/web/lib/qr/index.tsx:440-498](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L440-L498), [apps/web/lib/qr/api.tsx:13-85](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/api.tsx#L13-L85)
### Raster Canvas Drawing Execution Walkthrough
The raster rendering pipeline builds HTML5 canvas elements and converts them into data representations or raw DOM nodes. The execution proceeds through the following phases:
1. `getQRAsCanvas()` extracts parameters from `props`, resolving defaults for `size`, `level`, `bgColor`, `fgColor`, `margin`, `dotStyle`, and `markerColor`.
2. It calls `qrcodegen.QrCode.encodeText(value, ERROR_LEVEL_MAP[level]).getModules()` to retrieve the raw boolean module grid, computes `numCells` including margins, and calls `getImageSettings()` to determine logo bounds.
3. If an image is configured, it instantiates an `Image` object with `crossOrigin = "anonymous"` and invokes `waitUntilImageLoaded(image, imageSettings.src)`. If `calculatedImageSettings.excavation` is defined, it runs `excavatesModules(cells, calculatedImageSettings.excavation)`.
4. It queries `window.devicePixelRatio`, sets `canvas.height` and `canvas.width` to `size * pixelRatio`, derives the scaling factor via `(size / numCells) * pixelRatio`, and applies it using `ctx.scale(scale, scale)`.
5. It paints the background rectangle with `bgColor` and loops through dot styles (`rounded`, `extra-rounded`, or default square paths via `Path2D`) to fill data modules while omitting finder pattern cells via `isFinderPatternCell()`.
6. Finally, it renders the logo image using `ctx.drawImage()` if `haveImageToRender` is true, and returns the `canvas` instance or a data URL depending on the `getCanvas` flag.
Sources: [apps/web/lib/qr/index.tsx:440-598](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L440-L598), [apps/web/lib/qr/utils.tsx:307-317](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L307-L317)
> [!NOTE]
> During extra-rounded dot rendering, the pipeline inspects adjacent cells in all four cardinal directions (`top`, `right`, `bottom`, `left`) using an `isDark` helper. Connected sides dynamically suppress corner rounding to ensure smooth, contiguous shapes across adjacent modules.
Sources: [apps/web/lib/qr/index.tsx:507-533](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L507-L533)
### SVG Generation and Logo Embedding Pipelines
SVG rendering pipelines optimize DOM node counts by consolidating dark modules into single vector paths rather than creating individual `` nodes per cell. For instance, Level 1 symbols reduce node counts from 441 to just 2 DOM nodes (background and foreground paths).
| Pipeline Function | Output Type | Logo Image Handling Strategy |
| --- | --- | --- |
| `getQRAsSVGDataUri` | `Promise` (Data URI) | Converts source URLs via `getBase64Image()` to inline base64 data URIs inside `` tags. |
| `getQRAsSVG` | `JSX.Element` | Fetches base64-encoded representations via external proxy `https://wsrv.nl/?url=...&encoding=base64` and injects them into `` nodes. |
| `QRCodeSVG` | `JSX.Element` | Supports `isOGContext` flags; switches between standard SVG `` elements and absolute-positioned HTML `` tags for Open Graph rendering contexts. |
Sources: [apps/web/lib/qr/index.tsx:315-360](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/index.tsx#L315-L360), [apps/web/lib/qr/utils.tsx:454-531](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L454-L531), [apps/web/lib/qr/api.tsx:13-58](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/api.tsx#L13-L58)
> [!WARNING]
> When `isOGContext` is true and image excavation is requested with low error correction levels (`L` or `M`), `QRCodeSVG` automatically upgrades the effective error correction level to `Q` to compensate for modules removed by logo placement.
Sources: [apps/web/lib/qr/utils.tsx:473-476](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/qr/utils.tsx#L473-L476)
## Public QR API and Validation
### Overview
The public QR code API endpoint runs at the `/qr` route on the edge runtime, providing dynamic QR image generation with built-in schema validation, rate limiting, and intelligent logo resolution. The endpoint accepts query parameters, executes validation checks, and returns Open Graph-compatible SVG image responses.
Sources: [apps/web/app/api/qr/route.tsx:1-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L1-L18), [apps/web/lib/openapi/qr/index.ts:29-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/qr/index.ts#L29-L34)
### Call-Chain Execution Walkthrough
When an HTTP `GET` request hits the `/qr` route, the execution proceeds through a strict sequence of validation, rate enforcement, logo resolution, and rendering steps:
1. `GET()` extracts request URL search parameters and invokes `getQRCodeQuerySchema.parse(getSearchParams(req.url))` to validate and coerce query inputs against schema defaults.
2. It executes `ratelimitOrThrow(req, "qr")` to enforce rate-limiting constraints on the incoming request.
3. It calls `getQRCodeLogo({ url, logo, hideLogo })` to determine the correct branding asset for the target link.
4. Inside logo resolution, `getShortLinkViaEdge(url.split("?")[0])` queries the edge store for short link data; if no short link matches, it immediately returns `DUB_QR_LOGO`.
5. If a short link exists, `getWorkspaceViaEdge({ workspaceId: shortLink.projectId })` retrieves the associated project workspace. If the workspace plan is `"free"`, it returns `DUB_QR_LOGO`.
6. If `hideLogo` is set to true, it returns `null`. If a custom `logo` string is passed in the query parameters, it returns that logo.
7. If the link belongs to a Dub-owned domain (`isDubDomain(shortLink.domain)`) and no workspace logo is configured, it falls back to `DUB_QR_LOGO`. Otherwise, it queries `getDomainViaEdge(shortLink.domain)` to check for a custom domain logo, falling back sequentially to `workspace?.logo` and finally `DUB_QR_LOGO`.
8. Returning to `GET()`, it passes the resolved parameters and `qrCodeLogo` into `QRCodeSVG()` and wraps the resulting element in `ImageResponse` with `CORS_HEADERS`, setting response dimensions and headers before returning the final image.
Sources: [apps/web/app/api/qr/route.tsx:18-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L18-L58), [apps/web/app/api/qr/route.tsx:60-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L60-L104)
### Query Schema and Validation Reference
The query parameters accepted by the QR endpoint are validated using a Zod schema that enforces type coercion, defaults, and descriptive metadata for OpenAPI documentation generation.
| Parameter | Type / Schema | Default Value | Description / Validation Rule |
| --- | --- | --- | --- |
| `url` | `parseUrlSchema` | *None* (Required) | The target URL to generate a QR code for. |
| `logo` | `z.string().optional()` | `undefined` | The logo URL to embed. Restricted to paid plans on Dub. |
| `size` | `z.coerce.number().optional()` | `600` | The size of the QR code in pixels. |
| `level` | `z.enum(QR_LEVELS).optional()` | `"L"` | Error correction level (`L`, `M`, `Q`, `H`). |
| `fgColor` | `z.string().optional()` | `#000000` | Foreground color of the QR code in hex format. |
| `bgColor` | `z.string().optional()` | `#ffffff` | Background color of the QR code in hex format. |
| `hideLogo` | `booleanQuerySchema.optional()` | `false` | Whether to hide the logo. Restricted to paid plans. |
| `margin` | `z.coerce.number().optional()` | `DEFAULT_MARGIN` | Size of the margin around the QR code matrix. |
| `includeMargin` | `booleanQuerySchema.optional()` | `true` | **Deprecated**. Margin is included by default; use `margin` instead. |
Sources: [apps/web/lib/zod/schemas/qr.ts:11-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/qr.ts#L11-L67)
> [!WARNING]
> Passing custom `logo` parameters or setting `hideLogo` requires a paid workspace plan on Dub. Free-tier workspaces ignore custom logo overrides and automatically fall back to the default Dub QR logo (`DUB_QR_LOGO`).
Sources: [apps/web/app/api/qr/route.tsx:76-92](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L76-L92)
### API Endpoint Design Trade-offs
| Design Choice | Benefit | Cost |
| --- | --- | --- |
| Edge Runtime Execution (`export const runtime = "edge"`) | Ultra-low latency responses close to clients worldwide with fast cold starts. | Limited access to Node.js built-in modules and reliance on edge-compatible database clients. |
| Zod Schema Coercion (`z.coerce.number()`) | Automatically converts string query parameters (like `size=400`) into typed numbers. | Hides strict type mismatch errors from callers by attempting silent casting. |
| Centralized CORS Header Injection (`CORS_HEADERS`) | Ensures consistent cross-origin access (`Access-Control-Allow-Origin: *`) across both `GET` and `OPTIONS` preflight responses. | Exposes public generation endpoints to unrestricted embedding on external domains. |
Sources: [apps/web/app/api/qr/route.tsx:11-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L11-L16), [apps/web/app/api/qr/route.tsx:106-111](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/qr/route.tsx#L106-L111), [apps/web/lib/zod/schemas/qr.ts:19-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/qr.ts#L19-L25)
## Interactive Client Customization Modals
### Overview
Dub provides interactive client-side customization modals that allow users to visually configure QR code designs in real time, persist their preferences to local storage, and export finalized graphics in multiple formats. The customization interface spans across link-specific modals (`LinkQRModal`), partner link modals (`PartnerLinkQRModal`), and inline link builder components (`QRCodePreview`), all powered by the shared form body component `QRCodeDesignFields`.
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:55-79](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L55-L79), [apps/web/ui/modals/link-qr-modal.tsx:54-107](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx#L54-L107), [apps/web/ui/modals/partner-link-qr-modal.tsx:40-81](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/partner-link-qr-modal.tsx#L40-L81), [apps/web/ui/links/link-builder/qr-code-preview.tsx:23-76](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/links/link-builder/qr-code-preview.tsx#L23-L76)
### UI Modal Controls and State Management
The customization interface exposes granular styling controls for dot shapes, finder marker centers, finder marker borders, foreground colors, and logo visibility. Workspace-specific design states are synchronized with local storage using custom persistence hooks prefixed by workspace identifiers.
| Control Name | State Property | Supported Values / Options | Purpose |
| --- | --- | --- | --- |
| Dot Style | `dotStyle` | `"square"`, `"rounded"`, `"extra-rounded"` | Controls the rendering geometry of data modules within the QR matrix. |
| Marker Center | `markerCenterStyle` | `"square"`, `"circle"` | Customizes the central block shape inside the three finder pattern corners. |
| Marker Border | `markerBorderStyle` | `"square"`, `"rounded-square"`, `"circle"` | Defines the outer border geometry surrounding each finder pattern. |
| Foreground Color | `fgColor` | Hex color string (e.g., `#000000`) | Sets the primary color applied to data modules and markers. |
| Logo Toggle | `hideLogo` | `boolean` | Determines whether the center brand logo is displayed or excavated. |
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:34-51](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L34-L51), [apps/web/ui/modals/qr-code-design-fields.tsx:165-250](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L165-L250), [apps/web/ui/modals/link-qr-modal.tsx:79-85](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx#L79-L85)
> [!NOTE]
> Pro-tier restrictions apply to logo visibility controls. Non-pro workspaces have the logo toggle disabled, forcing the UI to fall back to the default Dub QR logo (`DUB_QR_LOGO`) without allowing custom hide toggles.
Sources: [apps/web/ui/modals/link-qr-modal.tsx:87-89](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx#L87-L89), [apps/web/ui/modals/link-qr-modal.tsx:160-194](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx#L160-L194)
### Interactive Preview Rendering and Debounced Updates
Live preview rendering combines React state management with debounced input handlers and animated transitions. Color inputs use `useDebouncedCallback` with a 500ms delay to prevent excessive re-rendering and matrix recalculation while users manipulate hex color pickers.
```typescript
// Call-chain execution for updating QR design state and preview rendering:
// setData() -> state mutation -> previewKey re-calculation -> AnimatePresence transition -> re-evaluation
const onColorChange = useDebouncedCallback(
(color: string) => setData((d) => ({ ...d, fgColor: color })),
500,
);
const previewKey = `${data.fgColor}-${data.hideLogo}-${data.dotStyle}-${data.markerCenterStyle}-${data.markerBorderStyle}-${data.markerColor ?? ""}`;
```
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:83-94](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L83-L94), [apps/web/ui/modals/qr-code-design-fields.tsx:130-152](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L130-L152)
> [!TIP]
> The preview container wraps the `` component inside `AnimatePresence` with a dynamic `previewKey` string. Any modification to dot styles, colors, or logo visibility triggers a smooth 100ms fade-and-blur transition.
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:130-152](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L130-L152)
### Format Exporting and Clipboard Handling
The preview header features dedicated download and copy action popovers (`DownloadPopover` and `CopyPopover`) supplied with precomputed `qrData` objects. These utilities interface with canvas and SVG export helpers to generate downloadable raster or vector assets as well as clipboard-ready payloads.
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:1-2](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L1-L2), [apps/web/ui/modals/qr-code-design-fields.tsx:106-123](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L106-L123)
### Client Customization Architecture Trade-offs
| Design Choice | Benefit | Cost |
| --- | --- | --- |
| Local Storage Persistence (`useLocalStorage`) | Retains user-configured design preferences across sessions per workspace without backend round-trips. | State can become stale if workspace context or branding assets change externally. |
| Debounced Color Handlers (`useDebouncedCallback`) | Prevents lagging UI performance during continuous color picker dragging. | Introduces a 500ms delay before preview updates reflect color picker input values. |
| Memoized QR Data Computation (`useMemo`) | Avoids redundant matrix generation computations on every minor parent re-render. | Requires strict dependency tracking across all design property parameters. |
Sources: [apps/web/ui/modals/qr-code-design-fields.tsx:83-91](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx#L83-L91), [apps/web/ui/modals/link-qr-modal.tsx:79-82](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/link-qr-modal.tsx#L79-L82), [apps/web/ui/shared/qr-code.tsx:30-54](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/shared/qr-code.tsx#L30-L54)
## Related
- [[Link Creation and Builder UI]]
---
## Technical docs: Custom Domains
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-management/custom-domains
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/api/domains/claim-dot-link-domain.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts)
- [apps/web/app/api/domains/domain/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts)
- [apps/web/app/api/domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts)
- [apps/web/lib/dynadot/register-domain.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dynadot/register-domain.ts)
- [apps/web/lib/api/domains/get-domain-response.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-response.ts)
- [apps/web/app/domain/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx)
- [apps/web/app/api/domains/domain/forward-instructions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/forward-instructions/route.ts)
- [apps/web/app/api/domains/domain/verify/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/verify/route.ts)
- [apps/web/app/api/domains/domain/validate/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/validate/route.ts)
- [apps/web/lib/api/domains/finalize-premium-domain-registration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/finalize-premium-domain-registration.ts)
- [apps/web/scripts/customers/annature/import-domains.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/annature/import-domains.ts)
- [apps/web/app/ee/api/admin/domains/register-premium/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/domains/register-premium/route.ts)
- [apps/web/app/ee/api/domains/register/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/register/route.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/custom/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/custom/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/domains/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/page.tsx)
- [apps/web/lib/api/domains/get-domain-search-availability.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-search-availability.ts)
- [apps/web/lib/api/domains/verify-domain.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/verify-domain.ts)
- [apps/web/ui/domains/domain-configuration.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx)
- [apps/web/ui/domains/domain-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-card.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/domains/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/domains/page-client.tsx)
- [apps/web/lib/api/domains/add-domain-vercel.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts)
- [apps/web/app/ee/api/domains/status/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/status/route.ts)
- [packages/utils/src/functions/domains.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/functions/domains.ts)
- [apps/web/ui/partners/program-link-configuration.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/program-link-configuration.tsx)
- [apps/web/lib/api/domains/initiate-premium-domain-registration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/initiate-premium-domain-registration.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/default-domain-selector.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx)
- [apps/web/lib/domain-connect/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/domain-connect/constants.ts)
- [apps/web/lib/api/domains/get-config-response.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-config-response.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/register/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/register/page.tsx)
## Overview
Custom domains enable workspaces to elevate brand recognition, boost click-through rates by routing links through dedicated branding, and fulfill program requirements across short links and partner networks. The custom domains module unifies domain lifecycle management, handling API-driven routing operations, registrar coordination with Dynadot for availability searches and purchases, automated provisioning of complimentary `.link` domains, Vercel infrastructure registration, SSL certificate management, DNS validation routines including Domain Connect, and intuitive dashboard configuration interfaces.
Sources: [apps/web/app/domain/page.tsx:16-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/page.tsx#L16-L19), [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/page.tsx:32-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx#L32-L38), [apps/web/ui/partners/program-link-configuration.tsx:243-243](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/program-link-configuration.tsx#L243-L243), [apps/web/lib/api/domains/claim-dot-link-domain.ts:15-163](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts#L15-L163), [apps/web/app/api/domains/route.ts:96-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L96-L235), [apps/web/app/api/domains/domain/route.ts:44-208](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L44-L208), [apps/web/lib/dynadot/register-domain.ts:24-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dynadot/register-domain.ts#L24-L65), [apps/web/lib/api/domains/get-domain-search-availability.ts:4-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-search-availability.ts#L4-L32), [apps/web/lib/api/domains/finalize-premium-domain-registration.ts:12-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/finalize-premium-domain-registration.ts#L12-L109), [apps/web/lib/api/domains/add-domain-vercel.ts:5-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts#L5-L39), [apps/web/app/api/domains/domain/verify/route.ts:16-139](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/verify/route.ts#L16-L139), [apps/web/ui/domains/domain-card.tsx:64-132](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-card.tsx#L64-L132)
## Domain Routing and REST Surface
### Overview
The REST surface for domains provides public and workspace-scoped endpoints for querying, creating, updating, validating, and checking the status of custom domains. These endpoints enforce workspace permissions, plan-tier access constraints, validation checks, and Vercel infrastructure integration.
Sources: [apps/web/app/api/domains/route.ts:22-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L22-L235), [apps/web/app/api/domains/domain/route.ts:28-217](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L28-L217), [apps/web/app/api/domains/domain/validate/route.ts:8-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/validate/route.ts#L8-L48), [apps/web/app/ee/api/domains/status/route.ts:13-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/status/route.ts#L13-L87)
### API Endpoint Reference
| Method & Route | Access Level | Required Permissions / Plan | Description |
| --- | --- | --- | --- |
| `GET /api/domains` | Workspace-scoped | `domains.read` | Retrieves all domains for a workspace with pagination, search filtering, and optional root link inclusion. |
| `POST /api/domains` | Workspace-scoped | Workspace membership | Creates a new domain, validates syntax, registers with Vercel if configured, and enforces plan domain limits. |
| `GET /api/domains/[domain]` | Workspace-scoped | `domains.read` | Fetches a single domain's configuration and properties by its slug. |
| `PATCH /api/domains/[domain]` | Workspace-scoped | Workspace membership | Updates an existing domain's configuration fields, logo, or handles a domain name change. |
| `GET /api/domains/[domain]/validate` | Session-scoped | Session required | Validates domain format, checks against `www` prefixes, reserved `.dub.link` subdomains, and conflicting records. |
| `GET /api/domains/status` | Workspace-scoped | `domains.read`, Enterprise plan | Checks availability status of one or more domains against registrar availability APIs. |
Sources: [apps/web/app/api/domains/route.ts:23-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L23-L94), [apps/web/app/api/domains/route.ts:96-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L96-L235), [apps/web/app/api/domains/domain/route.ts:28-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L28-L42), [apps/web/app/api/domains/domain/route.ts:44-217](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L44-L217), [apps/web/app/api/domains/domain/validate/route.ts:8-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/validate/route.ts#L8-L48), [apps/web/app/ee/api/domains/status/route.ts:13-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/status/route.ts#L13-L87)
### Domain Validation and Status Checks
The validation endpoint (`GET /api/domains/[domain]/validate`) evaluates incoming domain strings through a sequence of checks. It tests syntax via `isValidDomain()`, rejects domains starting with `www.`, and evaluates reserved subdomain rules. It checks if the domain already exists via `domainExists()`, and inspects active sites using a dual approach: a 3-second timeout HTTP `HEAD` request against both `https://` and `http://` URLs, falling back to a DNS resolution lookup if the HTTP probe fails. For enterprise workspaces, the `GET /api/domains/status` route checks bulk availability through Dynadot integration while filtering out domains already verified on Dub.
> [!NOTE]
> Subdomains ending with `.dub.link` bypass the active site configuration check during validation.
Sources: [apps/web/app/api/domains/domain/validate/route.ts:8-95](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/validate/route.ts#L8-L95), [apps/web/app/ee/api/domains/status/route.ts:13-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/status/route.ts#L13-L87)
### Plan-Tier Enforcement
Workspaces on the `free` plan are restricted from configuring advanced domain features. When processing `POST` or `PATCH` requests on domains, the API checks workspace plan limits and throws a `DubApiError` with code `forbidden` if free-tier workspaces attempt to set restricted properties.
```typescript
if (workspace.plan === "free") {
if (
logo ||
expiredUrl ||
notFoundUrl ||
assetLinks ||
appleAppSiteAssociation ||
isNonEmptyJson(deepviewData)
) {
throw new DubApiError({
code: "forbidden",
message: `You can only set Pro features on a Pro plan and above.`,
});
}
}
```
Sources: [apps/web/app/api/domains/route.ts:112-137](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/route.ts#L112-L137), [apps/web/app/api/domains/domain/route.ts:73-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/route.ts#L73-L98)
## Dynadot Search and Domain Purchasing
### Overview
The Dynadot integration layer manages domain search availability, quote calculations, and premium registration orchestration. When checking domain search availability, the system verifies whether a domain is registered on Dub; if verified, it returns an unavailable status. Otherwise, it queries Dynadot for availability across the primary domain and alternative suggestion forms (`get${domain}`, `try${domain}`, `use${domain}`).
Sources: [apps/web/lib/api/domains/get-domain-search-availability.ts:4-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-search-availability.ts#L4-L31)
### Premium Domain Registration Call Chain
Premium domain registration is coordinated through an administrative API route that enforces workspace plan and billing constraints, initiates payment collection via Stripe, and finalizes the registration record.
The execution walkthrough proceeds as follows:
1. `POST /api/admin/domains/register-premium` parses and normalizes domain inputs, checks that the domain ends with `.link`, and verifies the workspace exists.
2. `initiatePremiumDomainRegistration()` validates workspace billing constraints (rejecting free plans and active trials), ensures a Stripe payment method is attached, checks availability via `searchDomainsAvailability()`, and ensures the domain is not already registered.
3. A database transaction creates a `domainRenewal` invoice with status `processing` for the exact registration price in cents.
4. `createPaymentIntent()` generates a Stripe payment intent with idempotency keys tied to the invoice. If payment creation fails, the invoice is marked as `failed` and a `DubApiError` is thrown.
5. Upon successful payment charge, `finalizePremiumDomainRegistration()` checks domain availability, invokes `registerDomain({ domain, premium: true })` (which returns a mock success response with a 1-year expiration), removes any unverified domain variants, creates the verified domain record with renewal fees, provisions a root link, configures Vercel nameservers and ingress, and sends notification emails to workspace owners.
Sources: [apps/web/app/ee/api/admin/domains/register-premium/route.ts:16-85](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/domains/register-premium/route.ts#L16-L85), [apps/web/lib/api/domains/initiate-premium-domain-registration.ts:10-165](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/initiate-premium-domain-registration.ts#L10-L165), [apps/web/lib/api/domains/finalize-premium-domain-registration.ts:12-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/finalize-premium-domain-registration.ts#L12-L109), [apps/web/lib/dynadot/register-domain.ts:24-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dynadot/register-domain.ts#L24-L38)
### Dynadot Error Handling and Registration Statuses
Standard domain registrations interact with the Dynadot client using a 1-year duration, USD currency, and a preset coupon constant. Non-success statuses returned by Dynadot throw mapped exceptions or custom API errors.
| Status Code / Response | Description / Error Message |
| :--- | :--- |
| `success` | Domain registered successfully or mock response returned for premium domains. |
| `error` | Dynadot-specific error message passed directly to the client. |
| `not_available` | `"Domain not available."` |
| `system_busy` | `"System is busy. Please try again."` |
| `insufficient_funds` | `"Insufficient funds. Please add more funds to your account."` |
| `over_quota` | Triggered when Dynadot detects unusually high registration call frequency within a short timeframe. |
| `order_pending_process` | Order created for command, pending manual team investigation and processing. |
Sources: [apps/web/lib/dynadot/register-domain.ts:4-64](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dynadot/register-domain.ts#L4-L64)
> [!WARNING]
> Free-tier workspaces and workspaces with active billing trials are strictly blocked from registering `.link` domains. Attempting to initiate registration without an attached Stripe payment method throws a `forbidden` API error.
Sources: [apps/web/lib/api/domains/initiate-premium-domain-registration.ts:22-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/initiate-premium-domain-registration.ts#L22-L44)
> [!TIP]
> The database transaction in `initiatePremiumDomainRegistration` prevents duplicate concurrent charges by checking for existing `processing` invoices associated with the target domain slug before generating a Stripe payment intent.
Sources: [apps/web/lib/api/domains/initiate-premium-domain-registration.ts:83-102](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/initiate-premium-domain-registration.ts#L83-L102)
## Free Dot Link Domain Claiming
### Overview
Complimentary `.link` domains can be claimed by eligible paid workspaces during onboarding or partner program link configuration flows. The provisioning process enforces strict workspace checks, validates custom domain terms against edge config rules, registers the domain via Dynadot, provisions Vercel ingress and nameservers asynchronously, and dispatches notification emails to workspace owners.
Sources: [apps/web/lib/api/domains/claim-dot-link-domain.ts:15-163](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts#L15-L163), [apps/web/ui/partners/program-link-configuration.tsx:197-202](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/program-link-configuration.tsx#L197-L202)
### Call-Chain Execution Walkthrough
The claim workflow executes through a sequential series of validation, registration, and asynchronous background tasks:
1. `claimDotLinkDomain()` inspects workspace properties unless `skipWorkspaceChecks` is enabled. It verifies that the workspace plan is not `free`, a Stripe payment method (`stripeId`) exists, `dotLinkClaimed` is false, and billing trials are inactive via `isWorkspaceBillingTrialActive()`.
2. Edge config retrieves `customDomainTerms`, compiling them into a regular expression to validate that the requested domain does not violate prohibited terms.
3. `Promise.all` executes three operations concurrently: calling `registerDomain({ domain })`, counting workspace domains via `prisma.domain.count()`, and searching for an unverified domain match via `prisma.domain.findFirst()`.
4. If an unverified domain match is found, `markDomainAsDeleted()` cleans up the conflicting record.
5. A subsequent `Promise.all` creates the verified workspace domain record (setting `primary` if `totalDomains === 0` and creating a nested `registeredDomain` entry) and initializes a root redirect link using `createLink()` with `_root` key and `DEFAULT_LINK_PROPS`.
6. `waitUntil()` queues background tasks: adding the domain to Vercel via `addDomainToVercel()` followed by `configureVercelNameservers()`, sending notification emails via `sendDomainClaimedEmails()` (unless skipped), and setting `dotLinkClaimed: true` on the workspace project.
Sources: [apps/web/lib/api/domains/claim-dot-link-domain.ts:15-160](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts#L15-L160)
> [!WARNING]
> Free workspaces, workspaces lacking a Stripe ID, and workspaces currently undergoing a billing trial are blocked from claiming a free `.link` domain, returning a `forbidden` API error with specific failure messages.
Sources: [apps/web/lib/api/domains/claim-dot-link-domain.ts:29-57](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts#L29-L57)
### API Route and Workspace Enforcement
Enterprise API routes expose domain registration endpoints that bypass standard workspace trial restrictions under controlled administrative contexts.
| Route / Handler | Required Permissions | Required Plan | Validation & Behavior |
| :--- | :--- | :--- | :--- |
| `POST /api/domains/register` | `domains.write` | `enterprise` | Validates request body via `registerDomainSchema`, restricts execution to workspace IDs listed in `DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES`, and invokes `claimDotLinkDomain` with `skipWorkspaceChecks: true`. Returns HTTP 201 with registration response. |
Sources: [apps/web/app/ee/api/domains/register/route.ts:10-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/register/route.ts#L10-L35)
> [!TIP]
> When `skipWorkspaceChecks` is set to `true` (such as in enterprise registration routes), workspace billing plans and trial checks are bypassed, but input validation schemas and restricted workspace ID arrays are still strictly enforced.
Sources: [apps/web/app/ee/api/domains/register/route.ts:14-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/domains/register/route.ts#L14-L27), [apps/web/lib/api/domains/claim-dot-link-domain.ts:29-57](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/claim-dot-link-domain.ts#L29-L57)
## Vercel Ingress and SSL Provisioning
### Overview
Custom domains integration with Vercel infrastructure relies on automated REST API calls to provision domain records, manage SSL certificates, and configure nameservers. The system handles standard apex domains and wildcard subdomains, checking proxied status and configuring redirection rules.
Sources: [apps/web/lib/api/domains/add-domain-vercel.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts#L1-L39), [apps/web/lib/api/domains/get-domain-response.ts:1-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-response.ts#L1-L34), [apps/web/lib/api/domains/get-config-response.ts:1-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-config-response.ts#L1-L33)
### Vercel API Call-Chain Walkthrough
The domain lookup, configuration, and addition process follows specific execution paths when interacting with Vercel's REST endpoints:
1. `getDomainResponse(domain)` first evaluates `isProxiedDomain(domain)`. If true, it short-circuits and returns `{ verified: true }`.
2. Otherwise, it extracts the apex domain using `getApexDomain("https://" + domain)`. If the apex domain differs from the requested domain, it constructs a wildcard domain (`*.${apexDomain}`) and queries `getVercelDomainResponse(wildcardDomain)`.
3. If the wildcard response is verified, it returns the wildcard response directly; otherwise, it falls back to querying `getVercelDomainResponse(domain)` against Vercel API v9 (`/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain}`).
4. `addDomainToVercel(domain, { redirectToApex })` performs a similar apex and wildcard check before issuing a `POST` request to Vercel API v10 (`/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`), optionally attaching a `redirect` property pointing to the domain without `www` if `redirectToApex` is enabled.
5. `getConfigResponse(domain)` inspects configuration status by checking proxied domains or fetching from Vercel API v6 (`/v6/domains/${domain}/config`), falling back to wildcard configurations if the apex domain differs.
Sources: [apps/web/lib/api/domains/add-domain-vercel.ts:5-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts#L5-L39), [apps/web/lib/api/domains/get-domain-response.ts:4-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-response.ts#L4-L34), [apps/web/lib/api/domains/get-config-response.ts:4-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-config-response.ts#L4-L33)
> [!NOTE]
> If a wildcard subdomain response is already verified on Vercel, `getDomainResponse` and `addDomainToVercel` bypass creating or querying the specific subdomain individually, inheriting the wildcard verification status.
Sources: [apps/web/lib/api/domains/add-domain-vercel.ts:15-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts#L15-L22), [apps/web/lib/api/domains/get-domain-response.ts:25-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-response.ts#L25-L32)
### Vercel Integration Endpoints and Parameters
The codebase communicates with specific Vercel API versions using environment variables for authentication and project scoping.
| Function | Vercel API Endpoint | Method | Key Parameters & Headers |
| :--- | :--- | :--- | :--- |
| `getVercelDomainResponse` | `/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain}` | `GET` | Headers: `Authorization: Bearer ${process.env.VERCEL_API_KEY}`, `Content-Type: application/json`. Query param: `teamId=${process.env.TEAM_ID_VERCEL}`. |
| `addDomainToVercel` | `/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains` | `POST` | Body: `{ name: domain, redirect?: getDomainWithoutWWW(domain) }`. Query param: `teamId=${process.env.TEAM_ID_VERCEL}`. |
| `getVercelConfigResponse` | `/v6/domains/${domain}/config` | `GET` | Headers: `Authorization: Bearer ${process.env.VERCEL_API_KEY}`, `Content-Type: application/json`. Query param: `teamId=${process.env.TEAM_ID_VERCEL}`. |
Sources: [apps/web/lib/api/domains/get-domain-response.ts:4-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-domain-response.ts#L4-L16), [apps/web/lib/api/domains/add-domain-vercel.ts:23-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/add-domain-vercel.ts#L23-L38), [apps/web/lib/api/domains/get-config-response.ts:4-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/get-config-response.ts#L4-L14)
## DNS Verification and Domain Connect
### Overview
DNS verification polling and record validation depend on direct interaction with Vercel's verification APIs and internal state persistence. When clients invoke verification endpoints, the system queries domain status, inspects configuration conflicts, and triggers retry-backed verification routines against Vercel infrastructure.
Sources: [apps/web/app/api/domains/domain/verify/route.ts:1-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/verify/route.ts#L1-L47), [apps/web/lib/api/domains/verify-domain.ts:1-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/verify-domain.ts#L1-L41)
### Verification and Retry Call-Chain Walkthrough
The verification lifecycle follows an explicit sequence of calls when evaluating unverified domains:
1. `GET /api/domains/[domain]/verify` invokes `getDomainOrThrow` to validate workspace permissions and retrieve the target domain slug.
2. It executes `Promise.all([getDomainResponse(domain), getConfigResponse(domain)])` to fetch current Vercel domain and configuration metadata.
3. If `domainJson.verified` is false, it assigns status `"Pending Verification"` and calls `verifyDomainWithRetry(domain)`.
4. `verifyDomainWithRetry` loops up to `attempts` (defaulting to 3), waiting `delayMs` (defaulting to 2500ms) via `sleep(delayMs)` between iterations, and issues `verifyDomain(domain)`.
5. `verifyDomain` makes a `POST` request to Vercel API v9 (`/v9/projects/${process.env.VERCEL_PROJECT_ID}/domains/${domain.toLowerCase()}/verify?teamId=${process.env.TEAM_ID_VERCEL}`) with bearer token authorization.
6. Once `verifyDomainWithRetry` returns a verified response, the verification route re-checks configuration via `getConfigResponse(domain)`. If conflicts or misconfigurations exist, it updates Prisma storage with `verified: false`; otherwise, it records `verified: true` and triggers automated Domain Connect discovery flows.
Sources: [apps/web/app/api/domains/domain/verify/route.ts:16-109](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/verify/route.ts#L16-L109), [apps/web/lib/api/domains/verify-domain.ts:1-41](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/domains/verify-domain.ts#L1-L41)
> [!WARNING]
> If Vercel reports a verified state but a subsequent `getConfigResponse` check detects misconfigurations, the domain state is forced back to unverified in the database, and the verification status becomes `"Invalid Configuration"`.
Sources: [apps/web/app/api/domains/domain/verify/route.ts:69-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/verify/route.ts#L69-L79)
### DNS Record Constants and Forwarding Instructions
The system defines specific custom DNS record values and constants for configuring A records, CNAME records, and Domain Connect identifiers.
| Constant Name | Value | Purpose |
| :--- | :--- | :--- |
| `DUB_CUSTOM_DOMAIN_A_RECORD` | `76.76.21.21` | Target IP address for custom domain apex A records. |
| `DUB_CUSTOM_DOMAIN_CNAME` | `cname.dub.co` | Target target host for custom domain CNAME records. |
| `DOMAIN_CONNECT_PROVIDER_ID` | `dub.co` | Provider identifier used during Domain Connect discovery flows. |
| `DOMAIN_CONNECT_KEY_HOST` | `_dck1` | Hostname prefix used for Domain Connect verification keys. |
| `DEFAULT_DC_SERVICE_APEX` | `links-apex` | Default Domain Connect service name for apex domain configurations. |
| `DEFAULT_DC_SERVICE_SUBDOMAIN` | `links-subdomain` | Default Domain Connect service name for subdomain configurations. |
| `DEFAULT_DC_SERVICE_EMAIL` | `email` | Default Domain Connect service name for email configurations. |
Sources: [apps/web/lib/domain-connect/constants.ts:1-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/domain-connect/constants.ts#L1-L8)
Clients can request formatted DNS instructions via `POST /api/domains/[domain]/forward-instructions`, which validates a JSON body containing an email address and `recordType` enum (`"A"` or `"CNAME"`). Rate limiting is enforced using Upstash policies (`forwardDnsInstructions` and `forwardDnsInstructionsTarget`), after which record arrays are compiled, appending A or CNAME records alongside any discovered TXT verification records, and delivered using `@dub/email` templates.
Sources: [apps/web/app/api/domains/domain/forward-instructions/route.ts:18-116](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/domains/%5Bdomain%5D/forward-instructions/route.ts#L18-L116)
## UI Lifecycle and Transfer Management
### Overview
The dashboard UI exposes domain management through client-side components including `DomainConfiguration`, `DomainCard`, onboarding wizards, and administrative domain renewal panels. These interfaces handle record selection, verification state polling, auto-configuration handoffs, and DNS record generation.
Sources: [apps/web/ui/domains/domain-configuration.tsx:27-39](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx#L27-L39), [apps/web/ui/domains/domain-card.tsx:64-90](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-card.tsx#L64-L90)
### Domain Configuration Call-Chain Walkthrough
The automated DNS configuration sequence coordinates client selections with Domain Connect endpoints:
1. `DomainConfiguration` initializes `recordType` based on `getSubdomain(domainJson.name, domainJson.apexName)` — defaulting to `"CNAME"` if a subdomain exists, or `"A"` for apex domains.
2. Clicking the auto-configure button triggers `handleAutoConfigure`, which issues a `POST` request to `/api/domains/${encodeURIComponent(domain)}/domain-connect/apply?workspaceId=${workspaceId}` with a JSON body specifying `returnTo`.
3. The response JSON is validated for an `applyUrl`. If present, `isAllowedSyncUXOrigin(json.applyUrl)` checks the origin against allowed Domain Connect SyncUX providers.
4. When validated, the browser assigns the URL via `window.location.assign(json.applyUrl)`, redirecting the user to their DNS provider's authorization screen.
Sources: [apps/web/ui/domains/domain-configuration.tsx:43-98](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx#L43-L98)
> [!WARNING]
> If the server returns an `applyUrl` from an unverified or unallowed origin, `isAllowedSyncUXOrigin` rejects the redirect, and a toast error is dispatched without navigating away.
Sources: [apps/web/ui/domains/domain-configuration.tsx:85-90](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx#L85-L90)
### Configuration Form States and Record Display
The `DomainConfiguration` component adapts its rendered DNS records and UI warnings depending on the domain verification status and record conflict checks.
| Data Status | Record Type Tab | Rendered DNS Instruction & Records | Warning / Notice |
| :--- | :--- | :--- | :--- |
| `Conflicting DNS Records` | Auto-selected based on conflict type (`A` or `CNAME`) | Lists conflicting records to remove, followed by the target DUB record (`76.76.21.21` or `cname.dub.co`). | None |
| `Unknown Error` | N/A | Renders `data.response.domainJson.error.message`. | None |
| `Pending Verification` / Default | `A` or `CNAME` tabs | Configures apex or subdomain with `DUB_CUSTOM_DOMAIN_A_RECORD` (`76.76.21.21`) or `DUB_CUSTOM_DOMAIN_CNAME` (`cname.dub.co`) with TTL `86400`, plus any discovered `TXT` verification records. | Ownership transfer warning if TXT verification is present; otherwise TTL propagation notice. |
Sources: [apps/web/ui/domains/domain-configuration.tsx:100-216](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx#L100-L216), [apps/web/ui/domains/domain-configuration.tsx:267-340](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/domains/domain-configuration.tsx#L267-L340)
### Onboarding Domain Selectors and Admin Operations
During user onboarding, `DefaultDomainSelector` renders selectable `DomainOption` cards for connecting custom domains, claiming free `.link` domains, or setting up `.dub.link` subdomains. Each option tracks user interaction via Plausible analytics and advances the onboarding flow using `continueTo(step)`.
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/default-domain-selector.tsx:25-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx#L25-L144), [apps/web/app/app.dub.co/onboarding/onboarding/steps/domain/default-domain-selector.tsx:173-248](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx#L173-248)
For administrative domain management, enterprise admin dashboards provide actions for premium `.link` domain registration invoices, subscription renewals, and domain refreshing (`Remove and re-add domain from Vercel`).
Sources: [apps/web/app/ee/admin.dub.co/dashboard/domains/page.tsx:5-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/page.tsx#L5-37)
## Related
- [[Routing and Multitenancy]]
- [[Link Resolution and Redirection]]
---
## Technical docs: GET Count or group links
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/admincountlinks
## Parameters
## Responses
## Try It
---
## Technical docs: Tinybird Analytics Engine
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-and-tracking/tinybird-analytics-engine
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/tinybird/record-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-lead.ts)
- [apps/web/lib/postback/record-postback-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/record-postback-event.ts)
- [apps/web/lib/tinybird/record-webhook-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-webhook-event.ts)
- [apps/web/lib/tinybird/record-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-sale.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/lib/tinybird/get-lead-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-events.ts)
- [apps/web/scripts/tinybird/get-sale-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/get-sale-events.ts)
- [apps/web/app/ee/api/track/lead/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts)
- [apps/web/lib/tinybird/record-click-zod.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts)
- [apps/web/lib/tinybird/record-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-link.ts)
- [apps/web/lib/tinybird/log-import-error.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/log-import-error.ts)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [apps/web/lib/postback/get-postback-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/get-postback-events.ts)
- [apps/web/lib/tinybird/get-customer-events-tb.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-customer-events-tb.ts)
- [apps/web/lib/zod/schemas/leads.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/leads.ts)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/scripts/tinybird/delete-lead-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/delete-lead-event.ts)
- [apps/web/scripts/tinybird/update-lead-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-lead-event.ts)
- [apps/web/lib/tinybird/get-webhook-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-webhook-events.ts)
- [apps/web/lib/analytics/get-analytics.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts)
- [apps/web/lib/tinybird/get-lead-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-event.ts)
- [apps/web/lib/api/audit-logs/record-audit-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts)
- [apps/web/scripts/customers/beehiiv/update-sale-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/update-sale-events.ts)
- [apps/web/lib/tinybird/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/index.ts)
- [apps/web/lib/integrations/segment/transform.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/segment/transform.ts)
- [apps/web/scripts/tinybird/update-sale-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-sale-event.ts)
- [apps/web/scripts/programs/3-import-customer-leads.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/3-import-customer-leads.ts)
- [apps/web/lib/analytics/get-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-events.ts)
## Overview
The Tinybird Analytics Engine powers high-performance event ingestion, real-time conversion tracking, and multi-dimensional timeseries aggregation across clicks, leads, and sales. Built around Tinybird data sources and pipes, it ingests high-volume click streams, attribute conversions, and synchronizes metadata to drive analytics and reporting dashboards.
Sources: [apps/web/lib/tinybird/record-lead.ts:5-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-lead.ts#L5-L8), [apps/web/lib/tinybird/record-sale.ts:5-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-sale.ts#L5-L8), [apps/web/lib/analytics/get-analytics.ts:99-123](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L99-L123)
## Tinybird Client and Pipeline Architecture
### Overview
The analytics subsystem relies on Tinybird builder utilities to instantiate ingestion endpoints and query pipes, routing streaming click data, webhook notifications, postback events, and customer timeline queries through strongly-typed Zod schemas.
Sources: [apps/web/lib/tinybird/record-click-zod.ts:1-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L1-L44), [apps/web/lib/tinybird/record-webhook-event.ts:1-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-webhook-event.ts#L1-L7), [apps/web/lib/postback/record-postback-event.ts:1-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/record-postback-event.ts#L1-L7), [apps/web/lib/tinybird/get-customer-events-tb.ts:1-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-customer-events-tb.ts#L1-L25)
### Ingestion and Query Endpoints
Tinybird ingestion and query pipelines are constructed via `tb.buildIngestEndpoint` and `tb.buildPipe` methods, mapping structured domain models to specific data sources and pipes.
| Endpoint / Pipe Variable | Target Source / Pipe | Configuration / Schema Constraints | Source File |
| :--- | :--- | :--- | :--- |
| `recordClickZod` | `dub_click_events` | `wait: true`, uses `recordClickZodSchema` (with default fallback strings/numbers for geographic, device, and request properties) | [apps/web/lib/tinybird/record-click-zod.ts:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L39-L43) |
| `recordWebhookEvent` | `dub_webhook_events` | Omits `timestamp` from `webhookEventSchemaTB` | [apps/web/lib/tinybird/record-webhook-event.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-webhook-event.ts#L4-L7) |
| `recordPostbackEvent` | `dub_postback_events` | Omits `timestamp` from `postbackEventInputSchemaTB` | [apps/web/lib/postback/record-postback-event.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/record-postback-event.ts#L4-L7) |
| `pipe` (`getCustomerEventsTB`) | `v2_customer_events` | Parameters and data typed via `z.any()` placeholders | [apps/web/lib/tinybird/get-customer-events-tb.ts:4-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-customer-events-tb.ts#L4-L8) |
Sources: [apps/web/lib/tinybird/record-click-zod.ts:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L39-L43), [apps/web/lib/tinybird/record-webhook-event.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-webhook-event.ts#L4-L7), [apps/web/lib/postback/record-postback-event.ts:4-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/record-postback-event.ts#L4-L7), [apps/web/lib/tinybird/get-customer-events-tb.ts:4-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-customer-events-tb.ts#L4-L8)
### Customer Events Retrieval Workflow
The customer timeline query flow wraps the underlying Tinybird pipe call inside an asynchronous helper function (`getCustomerEventsTB`) that accepts customer identifiers, optional link filters, and result constraints.
```typescript
export const getCustomerEventsTB = async ({
customerId,
linkIds,
limit,
}: {
customerId: string;
linkIds?: string[];
limit?: number;
}) => {
return await pipe({
customerId,
...(linkIds ? { linkIds } : {}),
...(limit ? { limit } : {}),
});
};
```
Sources: [apps/web/lib/tinybird/get-customer-events-tb.ts:10-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-customer-events-tb.ts#L10-L24)
> [!NOTE]
> Ingestion endpoints such as `recordClickZod` explicitly configure synchronous waiting (`wait: true`), ensuring that event payloads validated by `recordClickZodSchema` are confirmed upon insertion into the `dub_click_events` data source.
> Sources: [apps/web/lib/tinybird/record-click-zod.ts:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L39-L43)
## Click Ingestion and Redis Buffering
### Overview
The click recording subsystem manages incoming link requests through validation checks, bot filtration, QR code detection, metadata enrichment, and asynchronous background buffering. Handled primarily by `recordClick` in `apps/web/lib/tinybird/record-click.ts` and validated via `recordClickZod` using `recordClickZodSchema` in `apps/web/lib/tinybird/record-click-zod.ts`, the pipeline processes incoming click requests while applying rate-limiting deduplication via Redis.
Sources: [apps/web/lib/tinybird/record-click.ts:1-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L1-L236), [apps/web/lib/tinybird/record-click-zod.ts:1-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L1-L44)
### Execution Walkthrough and Validation
When a click request arrives, the `recordClick` function executes an explicit sequence of validation and enrichment steps before scheduling asynchronous persistence:
1. `!clickId` check: Validates that a `clickId` is present; returns `null` if absent.
2. `dub-no-track` header or query check: Inspects request headers and search parameters for the tracking opt-out flag.
3. `detectBot(req)`: Evaluates bot signatures against incoming user-agent data, skipping bot verification if `trigger === "deeplink"`.
4. `getIdentityHash(req)`: Computes an identity hash representing the client fingerprint for rate-limiting.
5. `recordClickCache.get(...)`: Checks Redis to deduplicate clicks for the domain and key pair from the same IP address within a one-hour window.
6. `detectQr(req)`: Checks if the request originated from a QR code scan, updating `trigger` to `"qr"`.
7. Metadata Enrichment: Extracts geolocation (`continent`, `country`, `region`, `city`, `latitude`, `longitude`, `vercel_region`), IP address, user-agent parsing (device, browser, OS, CPU architecture), and referrer information.
8. `waitUntil(...)`: Dispatches asynchronous background tasks including Tinybird event ingestion, Redis cache updates, and Redis stream event publishing.
Sources: [apps/web/lib/tinybird/record-click.ts:53-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L53-L233)
> [!WARNING]
> If `skipRatelimit` is false, a cache hit in `recordClickCache` causes the function to return `null` immediately without recording a click. If Redis throws an error during this check, the function catches the exception and returns `null` to prevent overwhelming Tinybird or MySQL.
> Sources: [apps/web/lib/tinybird/record-click.ts:84-100](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L84-L100)
### Event Payload Schema and Defaults
The ingested click payload conforms to `recordClickZodSchema`, which defines default fallback values for geographic, device, and request properties.
| Field Name | Type / Zod Definition | Default Value | Source File |
| :--- | :--- | :--- | :--- |
| `timestamp` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:5](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L5) |
| `identity_hash` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:6](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L6) |
| `click_id` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L7) |
| `workspace_id` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L8) |
| `link_id` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:9](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L9) |
| `domain` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:10](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L10) |
| `key` | `z.string()` | `""` | [apps/web/lib/tinybird/record-click-zod.ts:11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L11) |
| `country` | `z.string()` | `"Unknown"` | [apps/web/lib/tinybird/record-click-zod.ts:15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L15) |
| `device` | `z.string()` | `"Desktop"` | [apps/web/lib/tinybird/record-click-zod.ts:21](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L21) |
| `browser` | `z.string()` | `"Unknown"` | [apps/web/lib/tinybird/record-click-zod.ts:24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L24) |
| `os` | `z.string()` | `"Unknown"` | [apps/web/lib/tinybird/record-click-zod.ts:28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L28) |
| `bot` | `z.number()` | `0` | [apps/web/lib/tinybird/record-click-zod.ts:32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L32) |
| `qr` | `z.number()` | `0` | [apps/web/lib/tinybird/record-click-zod.ts:33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L33) |
| `referer` | `z.string()` | `"(direct)"` | [apps/web/lib/tinybird/record-click-zod.ts:34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L34) |
| `trigger` | `z.string()` | `"link"` | [apps/web/lib/tinybird/record-click-zod.ts:36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L36) |
Sources: [apps/web/lib/tinybird/record-click-zod.ts:4-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click-zod.ts#L4-L37)
### Asynchronous Buffering and Redis Integration
Once click metadata is compiled, `recordClick` buffers and persists events concurrently in the background using `waitUntil` and `Promise.allSettled`. The asynchronous block executes four parallel operations:
1. Tinybird Event Ingestion: Sends a POST request to `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true` authenticated via Bearer token.
2. Rate-Limit Cache Update: Calls `recordClickCache.set(...)` to store the click identifier in Redis for 1 hour, preventing duplicate clicks from the same identity hash.
3. Link Click Stream: Publishes a link click event via `publishLinkClickEvent(...)`.
4. Workspace Click Stream: Publishes the complete `clickData` payload via `publishWorkspaceClickEvent(clickData)`.
Sources: [apps/web/lib/tinybird/record-click.ts:169-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L169-L200)
> [!TIP]
> If `shouldCacheClickId` is enabled, the raw `clickData` object is cached directly in Redis under `clickIdCache:${clickId}` with a 5-minute expiration (`ex: 60 * 5`). This bridges the ingestion latency gap before newly recorded clicks become queryable directly inside Tinybird.
> Sources: [apps/web/lib/tinybird/record-click.ts:163-167](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L163-L167)
## Conversion Tracking: Leads and Sales
### Overview
Conversion tracking handles lead and sale events by routing incoming requests through server-side and client-side API endpoints, enforcing workspace authentication, validating payloads with Zod schemas, attributing events to existing or new customers, and recording metrics into Tinybird data sources. The core pipeline resolves customer identifiers, handles click lookups, enforces deduplication via Redis, and fans out side effects including partner commissions, workflows, webhooks, and conversion uploads.
Sources: [apps/web/app/ee/api/track/lead/route.ts:1-65](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L1-L65), [apps/web/lib/api/conversions/track-lead.ts:34-407](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L34-L407)
### Tinybird Ingestion Endpoints
Lead and sale events are pushed to Tinybird datasources using endpoints built with `tb.buildIngestEndpoint`. Each ingestion handler supports standard event records as well as variant payloads featuring explicit timestamp strings.
| Endpoint Function | Datasource | Event Schema Source | Sources |
| :--- | :--- | :--- | :--- |
| `recordLead` | `dub_lead_events` | `leadEventSchemaTB` | [apps/web/lib/tinybird/record-lead.ts:5-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-lead.ts#L5-L8) |
| `recordLeadWithTimestamp` | `dub_lead_events` | `leadEventSchemaTB.extend({ timestamp: z.string() })` | [apps/web/lib/tinybird/record-lead.ts:10-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-lead.ts#L10-L15) |
| `recordSale` | `dub_sale_events` | `saleEventSchemaTB` | [apps/web/lib/tinybird/record-sale.ts:5-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-sale.ts#L5-L8) |
| `recordSaleWithTimestamp` | `dub_sale_events` | `saleEventSchemaTB.extend({ timestamp: z.string() })` | [apps/web/lib/tinybird/record-sale.ts:10-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-sale.ts#L10-L15) |
Sources: [apps/web/lib/tinybird/record-lead.ts:1-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-lead.ts#L1-L16), [apps/web/lib/tinybird/record-sale.ts:1-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-sale.ts#L1-L16)
### Lead Tracking Execution Walkthrough
When a lead conversion request arrives at either the server-side API (`POST /api/track/lead`) or client-side API (`POST /api/track/lead/client`), control passes through authentication wrappers and validation layers before executing `trackLead()`.
The `trackLead` operation executes the following call chain:
1. `prisma.customer.findUnique(...)` — Queries the database to locate any existing customer record matching the workspace and `customerExternalId`.
2. `redis.set(...)` — If `mode` is not `deferred`, attempts to set a 1-week Redis key `trackLead:${workspace.id}:${customerExternalId}:${stringifiedEventName}` with `nx: true` for event deduplication. If `res === null`, `isDuplicateEvent` is marked true.
3. `getClickEvent(...)` — Retrieves click metadata associated with the resolved `clickId`.
4. `prisma.link.findUnique(...)` — Fetches the referral link to verify ownership, active status (`disabledAt`), and workspace alignment.
5. `getOrCreateCustomer(...)` — If no customer record exists in PostgreSQL, provisions a new customer linked to the click, partner program, and country data.
6. `redis.set(...)` (Wait Mode) — If `mode === "wait"`, caches the lead event payload in Redis for 5 minutes under `leadCache:${customer.id}` and `leadCache:${customer.id}:${stringifiedEventName}` to bridge Tinybird ingestion latency.
7. `recordLead(...)` — Invokes the Tinybird ingestion endpoint in a `waitUntil` background block (unless `mode === "deferred"`).
8. Side-effect dispatchers — Concurrently executes `prisma.link.update(...)`, `prisma.project.update(...)`, `queuePartnerCommissionCreation(...)`, `executeWorkflows(...)`, `syncPartnerLinksStats(...)`, `sendWorkspaceWebhook(...)`, `queueGoogleAdsConversionUpload(...)`, and `sendPartnerPostback(...)`.
Sources: [apps/web/lib/api/conversions/track-lead.ts:50-392](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L50-L392)
> [!WARNING]
> If a request omits `clickId`, `trackLead` requires an existing customer record linked to the provided `customerExternalId` in order to inherit its stored `clickId`. If neither is found, the operation immediately throws a `bad_request` `DubApiError`.
> Sources: [apps/web/lib/api/conversions/track-lead.ts:60-72](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L60-L72)
### Client-Side Tracking and Origin Verification
Client-side lead tracking (`POST /api/track/lead/client`) is protected by publishable keys and verifies that request origins comply with workspace configurations.
```typescript
export const POST = withPublishableKey(
async ({ req, workspace }) => {
const body = await parseRequestBody(req);
const allowRequest = verifyAnalyticsAllowedHostnames({
allowedHostnames: (workspace?.allowedHostnames ?? []) as string[],
req,
});
if (!allowRequest) {
throw new DubApiError({
code: "forbidden",
message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace.`,
});
}
const parsed = trackLeadRequestSchema.parse(body);
const response = await trackLead({ ...parsed, workspace });
return NextResponse.json(response, { headers: COMMON_CORS_HEADERS });
},
{
requiredPlan: ["business", "advanced", "enterprise"],
},
);
```
Sources: [apps/web/app/ee/api/track/lead/client/route.ts:14-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L14-L60)
> [!NOTE]
> Client-side tracking routes automatically respond to `OPTIONS` preflight requests with `COMMON_CORS_HEADERS` and a `204` status code to support cross-origin browser requests.
> Sources: [apps/web/app/ee/api/track/lead/client/route.ts:62-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L62-L67)
## Schema Definitions and Event Types
### Overview
The analytics subsystem relies on rigorous Zod schemas, transformation pipelines, and data models to validate and ingest events into Tinybird. Data structures govern lead conversions, link metadata recordings, error logging, and external integration mappings.
Sources: [apps/web/lib/zod/schemas/leads.ts:1-147](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/leads.ts#L1-L147), [apps/web/lib/tinybird/record-link.ts:1-90](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-link.ts#L1-L90), [apps/web/lib/tinybird/log-import-error.ts:1-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/log-import-error.ts#L1-L8), [apps/web/lib/integrations/segment/transform.ts:1-137](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/segment/transform.ts#L1-L137)
### Lead and Link Schema Definitions
Incoming tracking requests and database records undergo validation using strongly typed Zod definitions. The `trackLeadRequestSchema` enforces constraints on fields such as `clickId`, `eventName`, `customerExternalId`, and tracking `mode`.
Sources: [apps/web/lib/zod/schemas/leads.ts:8-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/leads.ts#L8-L69)
| Schema Name | Target Source / Datasource | Key Validation Rules |
| :--- | :--- | :--- |
| `trackLeadRequestSchema` | API Request Payload | `clickId` (string, trim), `eventName` (1-255 chars), `customerExternalId` (1-100 chars), `mode` (`async`, `wait`, `deferred`) |
| `leadEventSchemaTB` | Tinybird Ingestion (`dub_leads`) | Omits `timestamp` (generated by Tinybird), extends `clickEventSchemaTB` with `event_id`, `event_name`, `customer_id`, `metadata` |
| `dubLinksMetadataSchema` | Tinybird Ingestion (`dub_links_metadata`) | Transforms `created_at` date to SQL string format, maps `deleted` boolean to integer (`1`/`0`), sets defaults for nullable IDs |
Sources: [apps/web/lib/zod/schemas/leads.ts:8-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/leads.ts#L8-L101), [apps/web/lib/tinybird/record-link.ts:6-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-link.ts#L6-L44)
> [!NOTE]
> Tinybird ingestion schemas omit client-side timestamps so that Tinybird can generate its own authoritative ingestion timestamps.
> Sources: [apps/web/lib/zod/schemas/leads.ts:94-96](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/leads.ts#L94-L96)
### Transformation and Ingestion Pipelines
The data transformation layer normalizes internal database objects and webhook payloads before dispatching them to Tinybird endpoints or third-party analytics integrations.
```typescript
const transformLinkTB = (link: ExpandedLink) => {
const key = decodeKeyIfCaseSensitive({
domain: link.domain,
key: link.key,
});
return {
link_id: link.id,
domain: link.domain,
key,
url: link.url,
tag_ids: link.tags?.map(({ tag }) => tag.id) ?? [],
folder_id: link.folderId ?? "",
tenant_id: link.tenantId ?? "",
program_id: link.programId ?? "",
partner_id: link.partnerId ?? "",
partner_group_id: link.programEnrollment?.groupId ?? "",
partner_tag_ids:
link.programEnrollment?.programPartnerTags?.map(
({ partnerTagId }) => partnerTagId,
) ?? [],
workspace_id: link.projectId,
created_at: link.createdAt,
};
};
```
Sources: [apps/web/lib/tinybird/record-link.ts:52-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-link.ts#L52-L76)
For Segment integrations, webhook payloads are normalized through `formatEventForSegment()`, mapping event types to target properties:
| Webhook Event | Segment Event Name | User ID Source | Properties Included |
| :--- | :--- | :--- | :--- |
| `link.clicked` | `Link Clicked` | `click.id` (anonymousId) | `click`, `link` |
| `lead.created` | `capitalize(eventName)` | `customer.externalId` | `click`, `link`, `customer` |
| `sale.created` | `capitalize(eventName)` | `customer.externalId` | `click`, `link`, `customer`, `sale`, `revenue`, `currency` |
| `partner.enrolled` | `Partner Enrolled` | `partner.id` | `partner`, `links` |
Sources: [apps/web/lib/integrations/segment/transform.ts:17-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/segment/transform.ts#L17-L113)
> [!CAUTION]
> Unsupported Segment event types trigger an immediate runtime error (`Event ${event} is not supported for Segment.`), halting the transformation pipeline.
> Sources: [apps/web/lib/integrations/segment/transform.ts:31-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/segment/transform.ts#L31-L33)
## Timeseries Querying and Aggregation Engine
### Overview
Analytics metrics are retrieved and aggregated through Tinybird pipe queries and MySQL fallback paths. The querying subsystem parses parameters, handles timezones, formats dates for ClickHouse, and executes parameterized data pipelines for timeseries, events, and lead lookups.
Sources: [apps/web/lib/analytics/get-analytics.ts:27-196](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L27-L196), [apps/web/lib/analytics/get-events.ts:35-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-events.ts#L35-L168)
### Pipeline Execution and Query Flow
The analytics retrieval functions execute distinct initialization steps before dispatching calls to Tinybird pipes or relational databases.
```typescript
export const getLeadEvent = async ({
customerId,
eventName,
}: {
customerId: string;
eventName?: string | null;
}) => {
try {
const cachedLeadEvent = await redis.get(
`leadCache:${customerId}${eventName ? `:${eventName.toLowerCase().replaceAll(" ", "-")}` : ""}`,
);
if (cachedLeadEvent) {
return cachedLeadEvent;
}
} catch (_e) {}
try {
const { data } = await getLeadEventTB({ customerId, eventName });
return data[0];
} catch (error) {
console.error(
`[getLeadEvent] Error getting lead event for customerId: ${customerId}${eventName ? ` and eventName: ${eventName}` : ""}`,
error,
);
return null;
}
};
```
Sources: [apps/web/lib/tinybird/get-lead-event.ts:16-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-event.ts#L16-L45)
> [!NOTE]
> `getLeadEvent` checks Upstash Redis cache using a namespaced key (`leadCache:${customerId}...`) before falling back to querying the Tinybird `get_lead_event` pipe.
> Sources: [apps/web/lib/tinybird/get-lead-event.ts:24-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-event.ts#L24-L37)
### Analytics Query Parameters and Endpoints
Query parameters determine whether requests target optimized MySQL tables (such as all-time link clicks) or dynamic Tinybird pipes (`v4_count`, `v4_timeseries`, `v4_group_by`, `v4_events`, `get_lead_events`, `get_lead_event`).
Sources: [apps/web/lib/analytics/get-analytics.ts:54-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L54-L78), [apps/web/lib/analytics/get-analytics.ts:99-123](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L99-L123), [apps/web/lib/analytics/get-events.ts:77-86](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-events.ts#L77-L86), [apps/web/lib/tinybird/get-lead-events.ts:5-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-events.ts#L5-L11), [apps/web/lib/tinybird/get-lead-event.ts:7-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-event.ts#L7-L14)
| Pipe Function | Target Pipe / Datasource | Parameter Validation Schema | Purpose |
| :--- | :--- | :--- | :--- |
| `getAnalytics` (MySQL shortcut) | `Link` (MySQL table via PlanetScale) | N/A | Fast retrieval of all-time counts when `interval === "all"`, no custom dates/filters are set |
| `tb.buildPipe` (`getAnalytics`) | `v4_count`, `v4_timeseries`, `v4_group_by_link_metadata`, `v4_group_by` | `analyticsFilterTB` | Timeseries and dimensional grouping metrics (clicks, leads, sales, sale amount) |
| `tb.buildPipe` (`getEvents`) | `v4_events` | `eventsFilterTB` | Paginated raw event logs for clicks, leads, and sales with metadata parsing |
| `getLeadEvents` | `get_lead_events` | `z.object({ customerIds: z.string().array() })` | Batch retrieval of lead events for a list of customer identifiers |
| `getLeadEventTB` | `get_lead_event` | `z.object({ customerId: z.string(), eventName: z.string().nullish() })` | Single lead event lookup filtered by customer ID and optional event name |
Sources: [apps/web/lib/analytics/get-analytics.ts:54-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L54-L78), [apps/web/lib/analytics/get-analytics.ts:99-123](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L99-L123), [apps/web/lib/analytics/get-events.ts:77-86](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-events.ts#L77-L86), [apps/web/lib/tinybird/get-lead-events.ts:5-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-events.ts#L5-L11), [apps/web/lib/tinybird/get-lead-event.ts:7-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/get-lead-event.ts#L7-L14)
> [!CAUTION]
> When `groupBy === "count"`, `interval === "all"`, and no custom date ranges or dimensional filters are present, `getAnalytics` bypasses Tinybird entirely and queries PlanetScale MySQL directly.
> Sources: [apps/web/lib/analytics/get-analytics.ts:54-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-analytics.ts#L54-L78)
## Operational Maintenance and Event Lifecycle
### Overview
Operational maintenance across the analytics and event infrastructure handles batch backfills, deduplication, administrative corrections, and event deletion. Backfill scripts orchestrate large-scale data imports by reading source records (such as CSV files or webhook payloads), validating them via Zod schemas, checking existing database entries in PostgreSQL via Prisma, and batch-ingesting time-partitioned payloads into Tinybird datasources. Administrative adjustments correct event attribution by fetching historical records through Tinybird pipes, mutating link identifiers, recording updated timestamps, and issuing deletion conditions against base datasources and materialized views.
Sources: [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:21-75](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts#L21-L75), [apps/web/scripts/tinybird/delete-lead-event.ts:4-17](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/delete-lead-event.ts#L4-L17), [apps/web/scripts/tinybird/update-lead-event.ts:16-57](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-lead-event.ts#L16-L57), [apps/web/scripts/tinybird/update-sale-event.ts:7-48](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-sale-event.ts#L7-L48), [apps/web/scripts/customers/beehiiv/update-sale-events.ts:16-54](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/update-sale-events.ts#L16-L54), [apps/web/scripts/programs/3-import-customer-leads.ts:23-110](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/3-import-customer-leads.ts#L23-L110)
### Batch Import and Backfill Execution
Large-scale customer and lead import operations process data in structured phases. For instance, customer lead imports execute a multi-step sequence: `Papa.parse()` reads CSV data streams $\rightarrow$ records are filtered against existing Prisma customer and link records $\rightarrow$ click events are mapped with generated `nanoid(16)` identifiers $\rightarrow$ clicks are grouped by year to satisfy ClickHouse partition limits $\rightarrow$ newline-delimited JSON batches are posted to Tinybird $\rightarrow$ customer records are bulk-created via `prisma.customer.createMany` with duplicate skipping $\rightarrow$ lead events are recorded via `recordLeadWithTimestamp()` $\rightarrow$ link statistics are updated and synced via `syncPartnerLinksStats()`.
Sources: [apps/web/scripts/programs/3-import-customer-leads.ts:24-324](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/3-import-customer-leads.ts#L24-L324)
> [!WARNING]
> ClickHouse enforces a maximum of 12 partitions (months) for a given event backfill operation. Backfill scripts must reduce and group records by calendar year or month before submitting NDJSON payloads toTinybird.
> Sources: [apps/web/scripts/programs/3-import-customer-leads.ts:157-169](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/3-import-customer-leads.ts#L157-L169)
### Administrative Data Updates and Deletion Scripts
Because Tinybird datasources are immutable append-only logs, updating an event (such as migrating a customer lead or sale to a new link ID) requires a two-step mutation pattern: recording the corrected event with its original timestamp, followed by issuing a programmatic delete condition against both the base datasource and its materialized view.
Sources: [apps/web/scripts/tinybird/delete-lead-event.ts:5-17](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/delete-lead-event.ts#L5-L17), [apps/web/scripts/tinybird/update-lead-event.ts:33-57](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-lead-event.ts#L33-L57), [apps/web/scripts/tinybird/update-sale-event.ts:23-48](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-sale-event.ts#L23-L48), [apps/web/scripts/customers/beehiiv/update-sale-events.ts:32-52](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/update-sale-events.ts#L32-L52)
| Maintenance Script | Target Datasources | Deletion / Update Mechanism | Purpose |
| :--- | :--- | :--- | :--- |
| `delete-lead-event.ts` | `dub_lead_events`, `dub_lead_events_mv` | POST `delete_condition=customer_id = '...'` | Purge specific customer lead events across base and materialized views |
| `update-lead-event.ts` | `dub_lead_events`, `dub_lead_events_mv` | Fetch via `internal_get_lead_events` pipe $\rightarrow$ record via `recordLeadWithTimestamp` $\rightarrow$ delete old `link_id` | Migrate customer lead association from an old link to a new link |
| `update-sale-event.ts` | `dub_sale_events`, `dub_sale_events_mv` | Fetch via `internal_get_sale_events` pipe $\rightarrow$ record via `recordSaleWithTimestamp` $\rightarrow$ delete old `link_id` | Migrate customer sale records to a corrected partner link |
| `update-sale-events.ts` (Beehiiv) | `dub_sale_events`, `dub_sale_events_mv` | Fetch via `internal_get_events` pipe $\rightarrow$ record via `recordSaleWithTimestamp` $\rightarrow$ delete by `link_id` and customer ID | Batch update Beehiiv customer sale event link attributions |
Sources: [apps/web/scripts/tinybird/delete-lead-event.ts:5-39](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/delete-lead-event.ts#L5-L39), [apps/web/scripts/tinybird/update-lead-event.ts:7-83](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-lead-event.ts#L7-L83), [apps/web/scripts/tinybird/update-sale-event.ts:4-73](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-sale-event.ts#L4-L73), [apps/web/scripts/customers/beehiiv/update-sale-events.ts:7-74](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/update-sale-events.ts#L7-L74)
> [!CAUTION]
> Administrative deletion requests sent to Tinybird endpoints (`/v0/datasources/{dataSource}/delete`) require `application/x-www-form-urlencoded` payloads containing a `delete_condition` parameter. Both base tables and their corresponding `_mv` materialized views must be updated independently using `Promise.allSettled`.
> Sources: [apps/web/scripts/tinybird/delete-lead-event.ts:8-38](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/delete-lead-event.ts#L8-L38), [apps/web/scripts/tinybird/update-lead-event.ts:44-57](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/tinybird/update-lead-event.ts#L44-L57)
## Related
- [[Conversion and Event Tracking]]
- [[Analytics Dashboard and Querying]]
---
## Technical docs: GET Get admin links list
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/admingetlinks
## Parameters
## Responses
## Try It
---
## Technical docs: Conversion and Event Tracking
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-and-tracking/conversion-and-event-tracking
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/track/lead/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/lib/integrations/google-ads/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts)
- [apps/web/app/ee/api/track/sale/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [apps/web/lib/api/conversions/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/app/ee/api/track/sale/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/ee/api/google-ads/conversion-actions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts)
- [apps/web/app/ee/api/shopify/pixel/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts)
- [apps/web/lib/api/commissions/create-manual-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts)
- [apps/web/app/ee/api/google-ads/upload-conversion/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/lib/integrations/google-ads/upload-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
- [apps/web/lib/api/customers/reattribute-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/lib/openapi/track/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/track/index.ts)
- [apps/web/lib/integrations/singular/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/singular/track-lead.ts)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts)
- [apps/web/lib/analytics/get-customer-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-customer-events.ts)
## Overview
Conversion and event tracking form the core infrastructure for capturing visitor interactions, link performance, and downstream attribution across the system. This subsystem solves the complexity of bridging client-side click handling with server-side business intelligence by offering secure client ingestion endpoints, edge caching routers, robust bot filtering pipelines, and server-side attribution engines for both leads and sales. Key design decisions include utilizing Redis for low-latency deduplication and caching, Tinybird for high-throughput analytical event logging, and integrated webhooks or direct API handlers to support external processors like Stripe, Shopify, AppsFlyer, Singular, and Google Ads. By orchestrating automated workflows, partner commission triggers, and historical event replay pipelines, the tracking layer ensures reliable attribution and unified analytics across the entire ecosystem.
Sources: [apps/web/app/ee/api/track/lead/client/route.ts:1-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L1-L60), [apps/web/app/ee/api/track/application/route.ts:41-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L41-L117), [apps/web/app/ee/api/track/visit/route.ts:17-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts#L17-L117), [apps/web/app/ee/api/track/click/route.ts:57-183](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts#L57-L183), [apps/web/app/ee/api/appsflyer/webhook/route.ts:27-177](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L27-L177), [apps/web/lib/integrations/google-ads/api.ts:443-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L443-L503), [apps/web/app/ee/api/track/sale/client/route.ts:13-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts#L13-L73), [apps/web/lib/api/conversions/track-sale.ts:363-531](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L363-L531), [apps/web/app/ee/api/track/open/route.ts:23-202](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L23-L202), [apps/web/lib/api/conversions/track-lead.ts:112-231](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L112-L231), [apps/web/app/ee/api/stripe/integration/webhook/checkout-session-completed.ts:153-324](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts#L153-L324), [apps/web/lib/tinybird/record-click.ts:22-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L22-L236)
## Client-Side Ingestion and Edge Routing
### Client-Side Ingestion and Edge Routing
Client-side ingestion relies on public Next.js API routes under `apps/web/app/(ee)/api/track/` to accept tracking payloads for visits, clicks, deep link opens, and client-side conversions (leads and sales). Each route handles cross-origin requests by returning `COMMON_CORS_HEADERS` and responding to preflight `OPTIONS` requests with status `204`.
Sources: [apps/web/app/ee/api/track/lead/client/route.ts:6-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L6-L67), [apps/web/app/ee/api/track/visit/route.ts:2-124](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts#L2-L124), [apps/web/app/ee/api/track/click/route.ts:6-190](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts#L6-L190), [apps/web/app/ee/api/track/sale/client/route.ts:6-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts#L6-L80), [apps/web/app/ee/api/track/open/route.ts:2-209](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L2-L209)
### Endpoint Routing and Schema Validation
Incoming requests undergo parsing and validation using Zod schemas before hitting downstream attribution wrappers or database resolvers. Click tracking validates domains via `getDomainWithoutWWW` and requires a link key, optionally accepting custom URLs and referrers. Visit and open endpoints resolve paths by extracting pathname segments, defaulting root paths to `_root`.
Sources: [apps/web/app/ee/api/track/click/route.ts:25-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts#L25-L62), [apps/web/app/ee/api/track/visit/route.ts:20-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts#L20-L34), [apps/web/app/ee/api/track/open/route.ts:31-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L31-L79)
| Endpoint Route | HTTP Method | Validation Schema | Required Plan / Auth |
| :--- | :--- | :--- | :--- |
| `/api/track/click` | POST | `trackClickSchema` | Public (`withAxiom`) |
| `/api/track/visit` | POST | Request body JSON (`domain`, `url`) | Public (`withAxiom`) |
| `/api/track/open` | POST | `trackOpenRequestSchema` | Public (`withAxiom`) |
| `/api/track/lead/client` | POST | `trackLeadRequestSchema` | `["business", "advanced", "enterprise"]` (`withPublishableKey`) |
| `/api/track/sale/client` | POST | `trackSaleRequestSchema` | `["business", "advanced", "enterprise"]` (`withPublishableKey`) |
Sources: [apps/web/app/ee/api/track/lead/client/route.ts:9-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L9-L60), [apps/web/app/ee/api/track/visit/route.ts:18-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts#L18-L27), [apps/web/app/ee/api/track/click/route.ts:25-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts#L25-L61), [apps/web/app/ee/api/track/sale/client/route.ts:9-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts#L9-L73), [apps/web/app/ee/api/track/open/route.ts:15-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L15-L33)
### Caching and Edge Resolution Architecture
To minimize database load, client endpoints parallelize lookups across Redis global caches using `redisGlobalWithTimeout`. For click tracking, the pipeline queries `recordClickCache` and `linkCache` simultaneously to check for existing click IDs and pre-fetched link properties.
```mermaid
sequenceDiagram
autonumber
participant Client
participant NextRoute as Next.js API Route
participant Redis as Redis Global Cache
participant EdgeDB as PlanetScale Edge DB
participant TB as Tinybird & Streams
Client->>NextRoute: POST /api/track/click
NextRoute->>Redis: mget(recordClickCache, linkCache)
alt Click ID or Link Cached
Redis-->>NextRoute: Return cached values
else Cache Miss
NextRoute->>EdgeDB: getLinkWithPartner() / getLinkViaEdge()
EdgeDB-->>NextRoute: Link and partner props
NextRoute->>Redis: linkCache.set() (via waitUntil)
end
NextRoute->>NextRoute: Verify allowed hostnames
alt New Unique Click
NextRoute->>TB: recordClick() -> ingest event & streams
end
NextRoute-->>Client: Return JSON response with clickId
```
Sources: [apps/web/app/ee/api/track/visit/route.ts:38-71](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts#L38-L71), [apps/web/app/ee/api/track/click/route.ts:66-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts#L66-L98), [apps/web/app/ee/api/track/open/route.ts:80-109](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts#L80-L109), [apps/web/lib/tinybird/record-click.ts:169-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L169-L233)
> [!NOTE]
> When a click ID is newly generated or recorded via `recordClick`, `shouldCacheClickId` instructs Redis to cache the full `clickData` object under `clickIdCache:${clickId}` with a 5-minute expiration (`ex: 60 * 5`) to bridge ingestion lag before events appear in Tinybird.
Sources: [apps/web/lib/tinybird/record-click.ts:163-167](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L163-L167)
### Hostname Verification and Security Controls
Client-side lead and sale tracking endpoints use `withPublishableKey` middleware alongside `verifyAnalyticsAllowedHostnames` to ensure requests originate from permitted domains configured on the workspace. If an unauthorized origin calls the endpoint, a `DubApiError` with code `forbidden` is thrown, referencing the settings dashboard URL.
> [!WARNING]
> Requests containing the `dub-no-track` HTTP header or `dub-no-track` query parameter are immediately dropped, returning `null` before bot detection or analytics recording executes.
Sources: [apps/web/app/ee/api/track/lead/client/route.ts:14-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts#L14-L28), [apps/web/app/ee/api/track/sale/client/route.ts:14-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts#L14-L28), [apps/web/lib/tinybird/record-click.ts:57-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L57-L62)
## Application Event Ingestion Pipeline
### Overview
The application event ingestion pipeline processes marketplace and program lifecycle tracking events such as visits and starts. The entry point handles incoming requests via `POST /api/track/application`, wrapping execution with Axiom logging and CORS headers.
Sources: [apps/web/app/ee/api/track/application/route.ts:41-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L41-L43), [apps/web/app/ee/api/track/application/route.ts:119-124](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L119-L124)
### Bot Filtering and Rate Limiting Execution
The pipeline executes initial validation and security checks in a strict call order before parsing request payloads.
1. `detectBot(req)` — Evaluates user agent and request characteristics against bot signatures; if a bot is detected, an immediate `202` response with `{ ok: true }` is returned.
2. `getIP()` — Resolves the client IP address for rate-limiting identification.
3. `assertRateLimit()` — Validates the IP against the `RATELIMIT_POLICIES.trackApplication` policy via Upstash.
4. `trackApplicationEventSchema.parse()` — Parses and validates the request body for `eventName`, `url`, and `referrer`.
Sources: [apps/web/app/ee/api/track/application/route.ts:44-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L44-L59)
> [!WARNING]
> Bot requests bypass database queries and rate-limit checks entirely, returning an HTTP `202` status code to prevent bot traffic from polluting marketplace application analytics.
Sources: [apps/web/app/ee/api/track/application/route.ts:44-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L44-L49)
### Program Resolution and Event Dispatch
After payload validation, the pipeline identifies the target program and dispatches lifecycle events based on the requested event name.
```mermaid
sequenceDiagram
autonumber
participant Client
participant API as POST /api/track/application
participant DB as Prisma Database
participant Redis as Redis Cache
Client->>API: POST tracking payload (eventName, url, referrer)
API->>API: detectBot() & assertRateLimit()
API->>API: identityProgramSlug(url)
alt Program Slug is Network Program
API->>API: Assign NETWORK_PROGRAM_ID
else Custom Program Slug
API->>DB: prisma.program.findUnique(slug)
DB-->>API: Program Record
end
alt eventName === "visit"
API->>API: trackVisitEvent()
API->>Redis: Check visit cookie & enrollment
API->>DB: prisma.programApplicationEvent.create()
else eventName === "start"
API->>API: trackStartEvent()
end
API-->>Client: 202 Accepted { ok: true }
```
Sources: [apps/web/app/ee/api/track/application/route.ts:44-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L44-L113)
| Step Name | Function / Method | Target / Entity | Purpose |
| :--- | :--- | :--- | :--- |
| Bot Detection | `detectBot(req)` | NextRequest | Filters automated scrapers and bots |
| Rate Limiting | `assertRateLimit()` | Upstash Redis | Enforces `RATELIMIT_POLICIES.trackApplication` by IP |
| Program Identification | `identityProgramSlug(url)` | URL string | Extracts program slug and marketplace flag |
| Program Lookup | `prisma.program.findUnique()` | Database | Resolves program ID from slug or network constant |
| Event Dispatch | `trackVisitEvent()` / `trackStartEvent()` | Database & Cookies | Records marketplace application lifecycle events |
Sources: [apps/web/app/ee/api/track/application/route.ts:44-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts#L44-L108)
## Server-Side Lead Attribution Engine
### Overview
The server-side lead attribution engine processes direct API requests, client SDK telemetry, and internal authentication flows to attribute lead conversion events to click IDs, resolve customer profiles, and trigger partner commissions and webhooks. The core HTTP endpoint is mounted at `POST /api/track/lead`, requiring authenticated workspace membership with business, advanced, or enterprise plans.
Sources: [apps/web/app/ee/api/track/lead/route.ts:9-11](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L9-L11), [apps/web/app/ee/api/track/lead/route.ts:61-65](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L61-L65)
### Lead Request Validation and Backwards Compatibility
Incoming payloads are parsed against a Zod validation schema that normalizes fields and handles legacy parameter naming for backwards compatibility.
| Parameter | Type | Required / Optional | Purpose |
| :--- | :--- | :--- | :--- |
| `clickId` | string | Optional (coerced to empty string) | Associated visitor click identifier |
| `eventName` | string | Optional | Name of the conversion event |
| `eventQuantity` | number | Optional | Multiplier quantity for the conversion event |
| `customerExternalId` | string | Optional (New) | Unique external identifier for the customer |
| `externalId` | string | Optional (Deprecated) | Legacy fallback field for customer external identifier |
| `customerId` | string | Optional (Deprecated) | Legacy fallback field for customer identifier |
| `customerName` | string | Optional | Human-readable name of the customer |
| `customerEmail` | string | Optional | Email address of the customer |
| `customerAvatar` | string | Optional | URL to the customer's avatar image |
| `mode` | string | Optional | Processing mode (`wait`, `deferred`, or standard) |
| `metadata` | object | Optional | Custom key-value metadata associated with the lead |
Sources: [apps/web/app/ee/api/track/lead/route.ts:14-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L14-L35)
> [!WARNING]
> The resolver checks `customerExternalId`, `externalId`, and `customerId` in sequential fallback order (`newExternalId || oldExternalId || oldCustomerId`). If all three resolve to a nullish value, a `bad_request` `DubApiError` is thrown immediately.
Sources: [apps/web/app/ee/api/track/lead/route.ts:37-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L37-L44)
### Lead Attribution Call Chain and Processing Execution
The lead attribution engine executes through a sequential pipeline from request handling to asynchronous post-processing.
```mermaid
sequenceDiagram
autonumber
participant Client
participant API as POST /api/track/lead
participant Track as trackLead()
participant Redis as Upstash Redis
participant Tinybird as Tinybird Analytics
participant DB as Prisma Database
Client->>API: POST /api/track/lead (clickId, customerExternalId, eventName)
API->>API: withWorkspace() & parseRequestBody()
API->>Track: trackLead(payload, workspace)
Track->>Redis: Redis setnx deduplication check (leadCache / 1 week)
alt Duplicate Event Detected
Track-->>API: Return cached or duplicate status
else New Event
Track->>Tinybird: getClickEvent({ clickId })
Tinybird-->>Track: clickData
Track->>DB: prisma.link.findUnique({ id: clickData.link_id })
DB-->>Track: link record (verify ownership & status)
Track->>DB: getOrCreateCustomer() resolve/create customer
alt mode === "wait"
Track->>Redis: Cache lead payload for 5 minutes
end
Track->>Tinybird: recordLead(leadEventPayload) (unless deferred)
Track->>DB: prisma.link.update() & prisma.project.update() usage
Track->>Track: queuePartnerCommissionCreation() & sendWorkspaceWebhook()
end
API-->>Client: JSON response
```
Sources: [apps/web/app/ee/api/track/lead/route.ts:12-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts#L12-L59), [apps/web/lib/api/conversions/track-lead.ts:112-321](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L112-L321)
### Internal Authentication Lead Tracking
Dub tracks authentication-triggered sign-up leads internally via `trackDubLead()`. This utility retrieves the visitor tracking cookie (`dub_id`), invokes the underlying SDK tracking method with a `"Sign Up"` event name, and subsequently purges tracking cookies.
```typescript
export const trackDubLead = async (user: User) => {
const cookieStore = await cookies();
const clickId = cookieStore.get("dub_id")?.value;
if (!clickId) {
console.log("No dub_id cookie found, skipping lead tracking...");
return;
}
// send the lead event to Dub
await dub.track.lead({
clickId,
eventName: "Sign Up",
customerExternalId: user.id,
customerName: user.name,
customerEmail: user.email,
customerAvatar: user.image,
});
// delete the cookies
cookieStore.delete("dub_id");
cookieStore.delete("dub_partner_data");
};
```
Sources: [apps/web/lib/auth/track-dub-lead.ts:5-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts#L5-L27)
> [!NOTE]
> OpenAPI path definitions register `/track/lead` under the `trackPaths` dictionary mapping directly to the POST handler implementation for automated API documentation generation.
Sources: [apps/web/lib/openapi/track/index.ts:6-9](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/track/index.ts#L6-L9)
## Server-Side Sale Attribution Engine
### Overview
The sale attribution engine handles conversion processing for revenue events, currency normalization, first-conversion detection, and partner commission triggers. Incoming sale requests are processed via the protected workspace endpoint at `POST /api/track/sale`, which requires workspace authentication and specific plan tiers (`business`, `advanced`, or `enterprise`) with `owner` or `member` roles.
Sources: [apps/web/app/ee/api/track/sale/route.ts:9-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts#L9-L69)
### Sale Processing and Currency Normalization Call Chain
When a sale request hits the API route, parameters are validated using Zod schemas supporting backward compatibility aliases (`customerExternalId`, `externalId`, and `customerId`). The execution pipeline flows through core validation, currency conversion, and event recording.
```mermaid
sequenceDiagram
autonumber
participant Client
participant API as POST /api/track/sale
participant Track as trackSale()
participant Currency as convertCurrency()
participant DB as Prisma Database
Client->>API: POST /api/track/sale (amount, currency, customerExternalId)
API->>API: withWorkspace() & parseRequestBody()
API->>Track: trackSale(payload, workspace)
Track->>Track: Resolve or create customer & validate lead event data
alt amount <= 0
Track-->>API: Return null sale response
else amount > 0
alt currency !== "usd"
Track->>Currency: convertCurrency({ currency, amount })
Currency-->>Track: converted currency & amount
end
Track->>DB: Check first conversion status (isFirstConversion)
Track->>DB: Record sale event & trigger partner commissions
end
API-->>Client: JSON response
```
Sources: [apps/web/app/ee/api/track/sale/route.ts:10-63](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts#L10-L63), [apps/web/lib/api/conversions/track-sale.ts:469-531](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L469-L531)
### Sale Request Parameters Reference
The sale ingestion payload accepts several standard and financial attributes to record and attribute revenue events accurately.
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `customerExternalId` | string | Yes (or fallback) | Unique external identifier for the customer |
| `externalId` | string | Optional | Deprecated fallback for `customerExternalId` |
| `customerId` | string | Optional | Deprecated fallback for `customerExternalId` |
| `amount` | number | Yes | Monetary value of the sale transaction |
| `currency` | string | Optional | Currency code for the transaction (defaults to `"usd"`) |
| `eventName` | string | Optional | Name of the sale event |
| `paymentProcessor` | string | Optional | Payment gateway processor name (e.g. `"stripe"`, `"custom"`) |
| `invoiceId` | string | Optional | External invoice reference ID |
| `leadEventName` | string | Optional | Associated lead event name |
| `metadata` | object | Optional | Custom key-value metadata associated with the sale |
Sources: [apps/web/app/ee/api/track/sale/route.ts:14-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts#L14-L36), [apps/web/lib/api/conversions/track-sale.ts:469-485](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L469-L485)
> [!WARNING]
> If the transaction `amount` is less than or equal to `0`, the sale tracking function immediately bypasses event recording and returns a null sale object, preventing zero-value or negative revenue pollution in analytics.
Sources: [apps/web/app/ee/api/track/sale/route.ts:41-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts#L41-L45), [apps/web/lib/api/conversions/track-sale.ts:493-500](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L493-L500)
### Currency Conversion and Commission Integration
Non-USD currencies are automatically normalized prior to sale persistence. When `currency !== "usd"`, the engine invokes `convertCurrency` to compute the converted amount and standardized currency code. Commission generation sources default to `CommissionSource.api` when manual or automated sales are processed through tracking routes.
```typescript
if (currency !== "usd") {
const { currency: convertedCurrency, amount: convertedAmount } =
await convertCurrency({
currency,
amount,
});
currency = convertedCurrency;
amount = convertedAmount;
}
```
Sources: [apps/web/lib/api/conversions/track-sale.ts:479-513](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L479-L513)
> [!NOTE]
> OpenAPI path definitions map `/track/sale` within the `trackPaths` schema dictionary directly to the POST sale tracking endpoint handler.
Sources: [apps/web/lib/openapi/track/index.ts:6-12](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/track/index.ts#L6-L12)
## Integration Ingestion and Webhooks
### Overview
Conversion attribution is synchronized from external services via dedicated ingestion webhooks and client-side tracking pixels. The platform verifies requests against provider IP ranges or security signatures before processing conversions from AppsFlyer, Singular, Shopify, and Stripe.
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:26-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L26-L49), [apps/web/app/ee/api/singular/webhook/route.ts:37-52](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L37-L52), [apps/web/app/ee/api/shopify/pixel/route.ts:20-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L20-L82)
### AppsFlyer and Singular Postback Processing
Both AppsFlyer and Singular ingest postback events via GET routes. The AppsFlyer webhook validates client IPs against `APPSFLYER_IP_RANGES`, parses the `appId` and `partnerEventId`, and matches the installation via Prisma. Singular maps incoming event names through `singularToDubEvent` before dispatching leads or sales.
```typescript
const singularToDubEvent = {
activated: "lead",
sng_complete_registration: "lead",
sng_subscribe: "sale",
sng_ecommerce_purchase: "sale",
__iap__: "sale", // In-app purchase
"Copy GAID": "lead", // Singular Device Assist
"copy IDFA": "lead", // Singular Device Assist
};
```
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:37-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L37-L79), [apps/web/app/ee/api/singular/webhook/route.ts:15-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L15-L23), [apps/web/app/ee/api/singular/webhook/route.ts:40-107](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L40-L107)
### Shopify Pixel and Order Caching
The Shopify pixel endpoint receives client-side pixel events (`clickId` and `checkoutToken`), enforces rate limiting via Upstash, validates the click event, and caches the association in `shopifyCheckoutCache` before triggering the order processing job.
```mermaid
sequenceDiagram
autonumber
participant Client as Shopify Pixel
participant API as POST /api/shopify/pixel
participant Cache as shopifyCheckoutCache
participant Job as tryDispatchShopifyOrderJob
Client->>API: POST { clickId, checkoutToken, shopDomain }
API->>API: Parse body & check rate limit
API->>API: Verify click event via getClickEvent()
API->>Cache: shopifyCheckoutCache.set({ checkoutToken, fields: { clickId } })
Cache-->>API: Stored checkout
API->>Job: tryDispatchShopifyOrderJob({ checkoutToken, checkout })
API-->>Client: 200 OK (CORS headers)
```
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:14-77](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L14-L77)
> [!WARNING]
> If either `checkoutToken` or `clickId` is missing from the incoming Shopify pixel payload, the request is immediately acknowledged with an OK response and skipped without performing attribution.
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:37-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L37-L45)
### Stripe Customer Sync and Promotion Code Attribution
Stripe webhooks synchronize customer records and handle checkout completions. When processing customer creation or updates via `syncCustomer`, the engine checks metadata for `dubClickId` and an external ID. If a customer checks out using a promotion code without an existing attribution link, `attributeViaPromotionCodeId` resolves the Stripe promotion code ID to a discount code in Dub, records a fake click event, and provisions the customer and lead event.
```typescript
export async function attributeViaPromotionCodeId({
promotionCodeId,
workspace,
mode,
customerDetails,
}: {
promotionCodeId: string;
workspace: Pick<
Project,
"id" | "defaultProgramId" | "stripeConnectId" | "webhookEnabled"
>;
mode: StripeMode;
customerDetails: PromoCodeCustomerDetails;
}) {
const promotionCode = await getPromotionCode({
promotionCodeId,
stripeAccountId: workspace.stripeConnectId!,
mode,
});
// ... resolves discountCode, records fake click, and creates customer
}
```
Sources: [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:22-86](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L22-L86), [apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:30-70](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts#L30-L70)
| Integration Handler | Inbound Route / Method | Auth / Verification Mechanism | Target Entity / Action |
| :--- | :--- | :--- | :--- |
| **AppsFlyer Webhook** | `GET /api/appsflyer/webhook` | `APPSFLYER_IP_RANGES` validation | `trackLead()` or `trackSale()` |
| **Singular Webhook** | `GET /api/singular/webhook` | `SINGULAR_IP_RANGES` validation | `trackSingularLeadEvent()` or `trackSingularSaleEvent()` |
| **Shopify Pixel** | `POST /api/shopify/pixel` | Upstash Rate Limit & CORS headers | `shopifyCheckoutCache` & order job dispatcher |
| **Stripe Customer Sync** | Customer Webhooks (`syncCustomer`) | Stripe Signature & Metadata extraction | Prisma `Customer` upsert & lead creation |
| **Stripe Promotion Code** | Checkout Session (`attributeViaPromotionCodeId`) | Stripe Promotion ID lookup | `recordFakeClick()` & discount code mapping |
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:37-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L37-L49), [apps/web/app/ee/api/singular/webhook/route.ts:40-52](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L40-L52), [apps/web/app/ee/api/shopify/pixel/route.ts:21-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L21-L54), [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:31-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L31-L62), [apps/web/app/ee/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts:30-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/attribute-via-promotion-code-id.ts#L30-L80)
## Google Ads Conversion Uploads
### Overview
The Google Ads integration handles server-side conversion uploads and click conversion synchronization by communicating with the Google Ads API and Data Manager service. When a conversion payload is processed, the system extracts advertising identifiers such as `gclid`, `gbraid`, or `wbraid` from click URLs, currency units are normalized, and jobs are queued or executed against Google Ads endpoints.
Sources: [apps/web/lib/integrations/google-ads/api.ts:9-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L9-L25), [apps/web/lib/integrations/google-ads/upload-conversion.ts:21-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L21-L47)
### Queueing and Execution Call-Chain
Conversion uploads follow a structured execution path from inbound requests down to API data ingestion. The operation proceeds through the following call chain:
`queueGoogleAdsConversionUpload()` → `qstash.publishJSON()` → `POST /api/google-ads/upload-conversion` → `uploadGoogleAdsConversion()` → `GoogleAdsApi.uploadClickConversion()` → `dataManagerFetch()`
1. `queueGoogleAdsConversionUpload()` validates that the click URL contains a valid click identifier (`gclid`, `gbraid`, or `wbraid`) and that the workspace has the Google Ads integration installed. It adjusts non-zero-decimal currency values and publishes a payload via QStash with a deduplication ID.
2. The endpoint handler `POST /api/google-ads/upload-conversion` receives the cron-triggered payload and delegates execution to `uploadGoogleAdsConversion()`.
3. `uploadGoogleAdsConversion()` retrieves installed integration settings from Prisma, matches the event name against lead or sale conversion mappings, and initializes a `GoogleAdsApi` client instance using OAuth tokens.
4. `GoogleAdsApi.uploadClickConversion()` constructs operating account destinations, ad identifiers, and event metadata (formatting timestamps via `formatGoogleAdsEventTimestamp`), and executes the upload through `dataManagerFetch()` targeting the `events:ingest` path.
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:49-210](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L49-L210), [apps/web/app/ee/api/google-ads/upload-conversion/route.ts:8-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts#L8-L13), [apps/web/lib/integrations/google-ads/api.ts:443-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L443-L503)
> [!WARNING]
> New integrations must utilize the Data Manager API (`events:ingest`) rather than the legacy `ConversionUploadService.UploadClickConversions` method when uploading offline click conversions.
Sources: [apps/web/lib/integrations/google-ads/api.ts:441-442](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L441-L442)
### Account Resolution and Conversion Actions
When listing conversion actions or authenticating requests across manager hierarchies, the system evaluates candidate login customer IDs using `getLoginCustomerIdCandidates()` to avoid permission errors.
```typescript
const candidates = getLoginCustomerIdCandidates({
customers: currentSettings.customers,
selectedCustomerId: customerId,
loginCustomerId: currentSettings.loginCustomerId,
});
```
Sources: [apps/web/app/ee/api/google-ads/conversion-actions/route.ts:57-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts#L57-L61), [apps/web/lib/integrations/google-ads/api.ts:541-595](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L541-L595)
| Google Ads API Method | Endpoint / Query | Purpose | Return Type |
| :--- | :--- | :--- | :--- |
| `listUploadClickConversionActions` | `searchStream` (`SELECT conversion_action.id, ... WHERE conversion_action.type = UPLOAD_CLICKS AND conversion_action.status = ENABLED`) | Queries enabled upload click conversion actions for a customer | `Promise` |
| `uploadClickConversion` | `dataManagerFetch` (`events:ingest`) | Uploads an offline click conversion with retry handling | `Promise<{ requestId: string }>` |
Sources: [apps/web/lib/integrations/google-ads/api.ts:415-440](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L415-L440), [apps/web/lib/integrations/google-ads/api.ts:443-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L443-L503)
> [!TIP]
> During upload execution, the system retries failed requests up to three times with exponential backoff (`1000 * Math.pow(2, attempt)`) before marking the conversion upload as failed.
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:196-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L196-L223)
## Attribution Replay and Historical Analytics
### Customer Reattribution and Historical Event Feeds
Customer reattribution workflows allow moving customer entities and reconciling their event histories between distinct identifiers, while historical analytics mechanisms retrieve and parse customer conversion events from Tinybird and MySQL.
The customer reattribution lifecycle manages stub verification, event planning, and transactional record recreation. `isReattributedCustomerStub()` validates whether a customer record is a stub by checking prefix patterns against `externalId` (`reattributed_`, `dummy_`, `retired_`) alongside null checks for `partnerId`, `linkId`, and `programId`. `getCustomerReattributeEvents()` fetches events for a given customer ID via `getCustomerEventsTB()` up to `CUSTOMER_REATTRIBUTION_EVENTS_LIMIT` (500), filtering for entries where the `event` property is strictly `"click"`, `"lead"`, or `"sale"`. `loadReattributeEventPlan()` evaluates old and new customer events to build an event plan capturing click existence, lead counts, sale counts, total sale amounts, and timestamps.
```typescript
export async function loadReattributeEventPlan({
oldCustomerId,
newCustomerId,
}: {
oldCustomerId: string;
newCustomerId: string;
}): Promise {
const [oldEvents, newEvents] = await Promise.all([
getCustomerReattributeEvents(oldCustomerId),
getCustomerReattributeEvents(newCustomerId),
]);
if (oldEvents.length >= CUSTOMER_REATTRIBUTION_EVENTS_LIMIT) {
throw new Error(
`Customer ${oldCustomerId} has too many events to reattribute (limit ${CUSTOMER_REATTRIBUTION_EVENTS_LIMIT}).`,
);
}
const sourceEvents = oldEvents.length > 0 ? oldEvents : newEvents;
const clickEvent = sourceEvents.find((event) => event.event === "click");
const leadEvent = sourceEvents.find((event) => event.event === "lead");
const saleEvents = sourceEvents.filter((event) => event.event === "sale");
return {
hasClick: Boolean(clickEvent),
hasLead: Boolean(leadEvent),
leadCount: leadEvent ? 1 : 0,
saleCount: saleEvents.length,
saleAmount: saleEvents.reduce(
(sum, event) => sum + (event.saleAmount ?? 0),
0,
),
leadTimestamp: leadEvent?.timestamp ?? null,
saleTimestamp: saleEvents[saleEvents.length - 1]?.timestamp ?? null,
};
}
```
Sources: [apps/web/lib/api/customers/reattribute-customer.ts:77-238](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L77-L238)
> [!WARNING]
> If a customer exceeds `CUSTOMER_REATTRIBUTION_EVENTS_LIMIT` (500 events) during reattribution planning, an error is thrown to prevent unbounded payload processing and memory exhaustion.
Sources: [apps/web/lib/api/customers/reattribute-customer.ts:22-220](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L22-L220)
### Batch Backfills and Historical Ingestion
The Framer batch backfill cron endpoint (`POST /api/cron/framer/backfill-leads-batch`) validates workspace authorization against `FRAMER_WORKSPACE_ID` (`clsvopiw0000ejy0grp821me0`), parses inbound request payloads via Zod, and queries Tinybird pipes alongside Prisma databases to process historical lead and sale events.
```typescript
export const POST = withWorkspace(
async ({ req, workspace }) => {
try {
if (workspace.id !== FRAMER_WORKSPACE_ID) {
throw new DubApiError({
code: "unauthorized",
message: "Unauthorized",
});
}
const originalPayload = schema.parse(
await parseRequestBody(req),
) as PayloadItem[];
```
Sources: [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:56-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts#L56-L69)
> [!TIP]
> Background updates to link statistics during batch backfills are delegated via Vercel's `waitUntil()` helper function to ensure immediate HTTP response return without blocking client connections.
Sources: [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts:331-379](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts#L331-L379)
### Customer Conversion Event Retrieval
`getCustomerEvents()` aggregates customer telemetry by querying Tinybird event data and hydrating link records from MySQL via `getLinksMap()`. It normalizes timestamps to UTC, maps processed regional and referer fields, parses click schemas, decodes case-sensitive links, and parses lead or sale metadata depending on the specific event type.
| Function Name | Source File | Purpose | Return Type |
| :--- | :--- | :--- | :--- |
| `getCustomerEvents` | `apps/web/lib/analytics/get-customer-events.ts` | Retrieves and normalizes customer click, lead, and sale events with link metadata | `Promise` |
| `getCustomerReattributeEvents` | `apps/web/lib/api/customers/reattribute-customer.ts` | Fetches and filters customer reattribution events up to the event limit | `Promise` |
| `loadReattributeEventPlan` | `apps/web/lib/api/customers/reattribute-customer.ts` | Compiles event totals, counts, and timestamps for customer reattribution | `Promise` |
| `recreateCustomerForReattribution` | `apps/web/lib/api/customers/reattribute-customer.ts` | Atomically updates old customer stubs and creates new reattributed customer records | `Promise` |
Sources: [apps/web/lib/analytics/get-customer-events.ts:13-84](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/get-customer-events.ts#L13-L84), [apps/web/lib/api/customers/reattribute-customer.ts:98-171](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L98-L171), [apps/web/lib/api/customers/reattribute-customer.ts:204-238](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L204-L238)
## Related
- [[Tinybird Analytics Engine]]
- [[Commission Rules and Rewards]]
---
## Technical docs: POST Generate Veriff session for a partner
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/admingenerateveriffsession
## Parameters
## Responses
## Try It
---
## Technical docs: Analytics Dashboard and Querying
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-and-tracking/analytics-dashboard-and-querying
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx)
- [apps/web/app/api/analytics/dashboard/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/usage-chart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/usage-chart.tsx)
- [apps/web/app/api/analytics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/program-analytics-shell.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/program-analytics-shell.tsx)
- [apps/web/app/ee/api/program-applications/analytics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/analytics/route.ts)
- [apps/web/ui/analytics/location-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx)
- [apps/web/app/api/og/analytics/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/og/analytics/route.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx)
- [apps/web/ui/analytics/device-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/earnings/earnings-composite-chart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/earnings/earnings-composite-chart.tsx)
- [apps/web/ui/analytics/toggle.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/toggle.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-filters.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/links/analytics/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/analytics/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-query.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/analytics/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/analytics/page.tsx)
- [apps/web/ui/analytics/events/events-tabs.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx)
- [apps/web/app/ee/api/admin/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts)
- [apps/web/ui/analytics/chart-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/chart-section.tsx)
- [apps/web/lib/swr/use-partner-earnings-timeseries.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-earnings-timeseries.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-timeseries-chart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-timeseries-chart.tsx)
- [apps/web/ui/analytics/analytics-provider.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-partners-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-partners-table.tsx)
- [apps/web/ui/analytics/partner-segments-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx)
- [apps/web/ui/analytics/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx)
- [apps/web/lib/swr/use-api-logs-timeseries.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts)
## Overview
The analytics and querying system in Dub provides comprehensive traffic, conversion, and revenue tracking across workspace short links, shared public dashboards, partner program portals, and administrative consoles. It addresses the complexity of multi-dimensional event monitoring by combining robust backend REST endpoints with Zod parameter validation, workspace authorization checks, and plan-based date range restrictions.
Sources: [apps/web/app/api/analytics/route.ts:1-139](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts#L1-L139), [apps/web/app/api/analytics/dashboard/route.ts:1-223](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts#L1-L223)
Design decisions prioritize client-side query string synchronization, context-based filter management via SWR data-fetching hooks, and modular visual components ranging from interactive timeseries area charts and conversion funnels to geographic bar lists and device breakdown cards.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/program-analytics-shell.tsx:1-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/program-analytics-shell.tsx#L1-L40), [apps/web/ui/analytics/analytics-provider.tsx:1-313](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L1-L313), [apps/web/ui/analytics/location-section.tsx:1-176](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L1-L176)
By integrating specialized enterprise partner program queries, commission tracking, and application funnels directly alongside standard link analytics, the system offers a unified architecture for monitoring digital performance and monetization metrics.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:1-212](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L1-L212), [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx:1-228](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx#L1-L228)
## Analytics API Endpoints and Security
The analytics system exposes public and internal REST API endpoints designed to ingest query parameters, validate inputs via Zod schemas, enforce workspace-level permissions, and check plan-based date restrictions. Query parameters are parsed and validated using schemas defined in `apps/web/lib/zod/schemas/analytics`, while administrative endpoints enforce role-based access control and invoice filtering.
Sources: [apps/web/app/api/analytics/dashboard/route.ts:1-223](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts#L1-L223), [apps/web/app/api/analytics/route.ts:1-139](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts#L1-L139), [apps/web/app/ee/api/admin/payouts/route.ts:1-123](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L1-L123)
The internal analytics route (`/api/analytics`) executes a strict sequence of validation and authorization checks wrapped by the `withWorkspace` middleware with required permission `analytics.read`.
Sources: [apps/web/app/api/analytics/route.ts:20-138](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts#L20-L138)
1. `throwIfClicksUsageExceeded(workspace)` — Verifies that the workspace has not exceeded its click usage limits.
2. `analyticsPathParamsSchema.parse(params)` — Parses path parameters to extract event and endpoint types, supporting legacy routes.
3. `parseAnalyticsQuery(searchParams)` — Parses URL search parameters into structured query filters.
4. `getDefaultProgramIdOrThrow(workspace)` and `getProgramOrThrow(...)` — Validates program IDs when a partner program filter is applied.
5. `verifyFolderAccess(...)` — Ensures the requesting user possesses `folders.read` permissions for the target folder.
6. `assertValidDateRangeForPlan(...)` — Restricts queried date intervals according to the workspace plan tier.
Sources: [apps/web/app/api/analytics/route.ts:22-109](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts#L22-L109)
> [!WARNING]
> Requests referencing invalid program IDs or exceeding workspace click thresholds trigger a `DubApiError` with status code `forbidden` or `bad_request`, halting execution before query generation.
Sources: [apps/web/app/api/analytics/route.ts:55-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/route.ts#L55-L60)
The public dashboard endpoint (`/api/analytics/dashboard`) allows unauthenticated or password-protected viewing of link and folder metrics without requiring workspace membership.
Sources: [apps/web/app/api/analytics/dashboard/route.ts:16-202](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts#L16-L202)
- **Lookup Resolution**: Resolves targets via `folderId` or a combination of `domain` and `key`, checking demo links (`DUB_DEMO_LINKS`) and database entries.
- **Password Protection**: `assertDashboardPassword` verifies cookies matching `dub_password_${dashboard.id}` against stored dashboard passwords.
- **Redis Caching**: Caches successful responses in Upstash Redis for 60 seconds using a key derived from JSON-serialized query parameters (`analyticsDashboardCache:${JSON.stringify(parsedParams)}`). Background writes use Vercel's `waitUntil`.
Sources: [apps/web/app/api/analytics/dashboard/route.ts:44-197](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts#L44-L197), [apps/web/app/api/analytics/dashboard/route.ts:204-222](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/dashboard/route.ts#L204-L222)
The enterprise admin payouts route (`/api/admin/payouts`) is protected by `withAdmin` and validates queries using `adminPayoutsQuerySchema`.
Sources: [apps/web/app/ee/api/admin/payouts/route.ts:12-22](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L12-L22)
| Parameter | Type / Schema | Default / Notes | Sources |
| :--- | :--- | :--- | :--- |
| `programId` | `z.string().optional()` | Optional program filter identifier | [apps/web/app/ee/api/admin/payouts/route.ts:14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L14) |
| `status` | `z.enum(InvoiceStatus).optional()` | Defaults to excluding `failed` status | [apps/web/app/ee/api/admin/payouts/route.ts:15](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L15), [apps/web/app/ee/api/admin/payouts/route.ts:65-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L65-L67) |
| `interval` | Analytics schema interval | Defaults to `"mtd"` | [apps/web/app/ee/api/admin/payouts/route.ts:18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L18), [apps/web/app/ee/api/admin/payouts/route.ts:26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L26) |
| `page` | Pagination schema | Defaults to page 1 via skip calculation | [apps/web/app/ee/api/admin/payouts/route.ts:20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L20), [apps/web/app/ee/api/admin/payouts/route.ts:88](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L88) |
| `pageSize` | Pagination schema | Fixed pageSize of 100 | [apps/web/app/ee/api/admin/payouts/route.ts:20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L20) |
Sources: [apps/web/app/ee/api/admin/payouts/route.ts:12-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L12-L90)
> [!NOTE]
> When `programId` is omitted, the admin payouts query automatically excludes internal test constants (`ACME_PROGRAM_ID`, `DEMO_PROGRAM_ID`) and staging slugs ending with `-staging`.
Sources: [apps/web/app/ee/api/admin/payouts/route.ts:43-64](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts#L43-L64)
## State Management and Query Hooks
State management and client-side querying across Dub analytics dashboards rely on specialized React hooks, context providers, URL search parameter synchronization, and SWR caching wrappers. These utilities coordinate view selections, filter states for dimensions such as partners, countries, and referral sources, and asynchronous timeseries data fetching.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-filters.tsx:22-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx#L22-L235), [apps/web/ui/analytics/analytics-provider.tsx:22-235](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L22-L235)
The `AnalyticsProvider` component initializes dashboard state by reading URL search parameters and persisting user preferences via local storage hooks. It supplies configuration context down the component tree through `AnalyticsContext`.
Sources: [apps/web/ui/analytics/analytics-provider.tsx:56-100](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L56-L100), [apps/web/ui/analytics/analytics-provider.tsx:128-156](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L128-L156)
```typescript
export default function AnalyticsProvider({
adminPage,
dashboardProps,
children,
}: PropsWithChildren<{
adminPage?: boolean;
dashboardProps?: AnalyticsDashboardProps;
}>) {
const searchParams = useSearchParams();
const { slug: workspaceSlug, plan: workspacePlan, domains } = useWorkspace();
const [requiresUpgrade, setRequiresUpgrade] = useState(false);
// ... resolves base paths, query parameters, and SWR total events fetching
}
```
Sources: [apps/web/ui/analytics/analytics-provider.tsx:101-113](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L101-L113)
> [!NOTE]
> The provider automatically detects whether the current view is an admin console, workspace dashboard, partner profile page, or public stats page, dynamically mapping API base paths and event endpoints accordingly.
Sources: [apps/web/ui/analytics/analytics-provider.tsx:158-201](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-provider.tsx#L158-L201)
Client components synchronize filter states and view modes directly with URL search parameters using helper hooks such as `useApplicationsAnalyticsQuery`, `useApplicationAnalyticsFilters`, and `useApiLogsTimeseries`.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts:20-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-query.ts#L20-L37), [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-filters.tsx:22-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx#L22-L235), [apps/web/lib/swr/use-api-logs-timeseries.ts:7-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts#L7-L45)
- **`useApplicationsAnalyticsQuery`**: Parses `applicationEvent` parameters into typed application stages (`started`, `submitted`, `approved`, defaulting to `visited`) and `view` parameters into views (`timeseries` or `funnel`).
- **`useApplicationAnalyticsFilters`**: Manages multidimensional filtering across `partnerId`, `country`, and `referralSource`, providing callbacks to select, remove, toggle negative operators (prefixing values with `-`), and clear all active filters while updating the URL via `queryParams`.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-query.ts:9-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-query.ts#L9-L37), [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/use-applications-analytics-filters.tsx:20-235](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/use-applications-analytics-filters.tsx#L20-L235)
Data fetching relies on `useSWR` coupled with custom helper hooks that construct query strings and enforce deduplication intervals or keep previous data during transitions.
Sources: [apps/web/lib/swr/use-partner-earnings-timeseries.ts:9-54](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-earnings-timeseries.ts#L9-L54), [apps/web/lib/swr/use-api-logs-timeseries.ts:7-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts#L7-L45)
| Hook Name | Target API Route | Key Configuration Options | Sources |
| :--- | :--- | :--- | :--- |
| `usePartnerEarningsTimeseries` | `/api/partner-profile/programs/[programId]/earnings/timeseries` | `dedupingInterval: 60000`, `keepPreviousData: true`, timezone resolution | [apps/web/lib/swr/use-partner-earnings-timeseries.ts:23-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-earnings-timeseries.ts#L23-L47) |
| `useApiLogsTimeseries` | `/api/logs/timeseries` | `keepPreviousData: true`, workspace ID check, method/status/route inclusion | [apps/web/lib/swr/use-api-logs-timeseries.ts:32-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts#L32-L38) |
Sources: [apps/web/lib/swr/use-partner-earnings-timeseries.ts:23-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-earnings-timeseries.ts#L23-L47), [apps/web/lib/swr/use-api-logs-timeseries.ts:32-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts#L32-L38)
> [!WARNING]
> When `enabled` evaluates to false or required identifiers like `partnerId` or `workspaceId` are missing, SWR hooks return `null` keys to safely skip fetching until context is fully established.
Sources: [apps/web/lib/swr/use-partner-earnings-timeseries.ts:23-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-earnings-timeseries.ts#L23-L26), [apps/web/lib/swr/use-api-logs-timeseries.ts:32-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-api-logs-timeseries.ts#L32-L33)
## Main Dashboard Shell and Layout
The top-level analytics interface is unified across workspace dashboards, public stats pages, and enterprise admin consoles by wrapping child views in the `Analytics` component and supplying context via `AnalyticsProvider`.
Sources: [apps/web/ui/analytics/index.tsx:4-31](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L4-L31)
The core shell mounts an `AnalyticsProvider` configured with optional `adminPage` and `dashboardProps` flags. Inside its consumer tree, it renders the sticky toolbar header (`AnalyticsToggle`) alongside a responsive grid containing the primary chart and `StatsGrid`.
Sources: [apps/web/ui/analytics/index.tsx:31-48](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L31-L48)
```typescript
export default function Analytics({
adminPage,
dashboardProps,
}: {
adminPage?: boolean;
dashboardProps?: AnalyticsDashboardProps;
}) {
return (
{({ dashboardProps }) => {
return (
);
}}
);
}
```
Sources: [apps/web/ui/analytics/index.tsx:23-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L23-L55)
Within `StatsGrid`, specific conversion tabs (`leads`, `sales`) or funnel views are conditionally hidden for workspaces on `free` or `pro` plans, preventing restricted metric rendering. Otherwise, it organizes top links, referrers, UTMs, locations, and device breakdowns into a responsive two-column grid.
Sources: [apps/web/ui/analytics/index.tsx:57-73](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L57-L73)
| Component / Subview | Containing Shell Condition | Purpose / Layout Role | Sources |
| :--- | :--- | :--- | :--- |
| `WorkspaceAnalytics` | `app.dub.co/(dashboard)/[slug]/links/analytics/page.tsx` | Workspace-scoped view wrapped in `Suspense`, `PageContent`, and `AnalyticsClient`. | [apps/web/app/app.dub.co/dashboard/slug/links/analytics/page.tsx:7-17](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/analytics/page.tsx#L7-L17) |
| `AdminAnalytics` | `admin.dub.co/(dashboard)/analytics/page.tsx` | Enterprise admin console view passing the `adminPage` prop to the `Analytics` shell. | [apps/web/app/ee/admin.dub.co/dashboard/analytics/page.tsx:5-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/analytics/page.tsx#L5-L13) |
| `TopLinks` | `!dashboardProps?.key` | Renders top performing links when viewing a broader workspace context rather than a single link slug. | [apps/web/ui/analytics/index.tsx:66-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L66-L67) |
Sources: [apps/web/app/app.dub.co/dashboard/slug/links/analytics/page.tsx:7-17](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/analytics/page.tsx#L7-L17), [apps/web/app/ee/admin.dub.co/dashboard/analytics/page.tsx:5-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/analytics/page.tsx#L5-L13), [apps/web/ui/analytics/index.tsx:66-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx#L66-L67)
The `AnalyticsToggle` component manages sticky positioning rules and computes layout styling based on whether `dashboardProps` or `adminPage` are present. It integrates advanced filtering selectors (`Filter.Select`) and date range pickers (`DateRangePicker`) with preset validations tied to workspace plans.
Sources: [apps/web/ui/analytics/toggle.tsx:40-165](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/toggle.tsx#L40-L165)
```typescript
export function AnalyticsToggle({
page = "analytics",
}: {
page?: "analytics" | "events";
}) {
const { slug, programSlug } = useParams();
const { plan, createdAt } = useWorkspace();
const { product } = useCurrentProduct();
const { queryParams, getQueryString } = useRouterStuff();
const {
domain,
key,
url,
adminPage,
partnerPage,
dashboardProps,
start,
end,
interval,
} = useContext(AnalyticsContext);
const scrolled = useScroll(120);
const { isMobile } = useMediaQuery();
...
```
Sources: [apps/web/ui/analytics/toggle.tsx:40-66](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/toggle.tsx#L40-L66)
> [!NOTE]
> Sticky positioning on `AnalyticsToggle` dynamically adjusts its top threshold depending on the context: `top-14` when rendering `dashboardProps` and `top-16` when rendering inside `adminPage`.
Sources: [apps/web/ui/analytics/toggle.tsx:173-176](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/toggle.tsx#L173-L176)
## Timeseries and Funnel Chart Visualizations
The chart visualization layer handles rendering for timeseries area charts, conversion funnels, event selection tabs, and tooltip formatting across workspace settings, enterprise program analytics, and general link analytics. The primary components powering this layer include `ChartSection`, `AnalyticsTimeseriesChart`, `AnalyticsChart`, and `EventsTabs`. These components coordinate with `AnalyticsContext` to fetch timeseries metrics from `/api/analytics` and switch between distinct view modes (`timeseries` and `funnel`).
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:13-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L13-L33), [apps/web/ui/analytics/chart-section.tsx:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/chart-section.tsx#L26-L36)
Event tabs let users toggle between tracking categories (`clicks`, `leads`, `sales`), updating URL parameters through `onEventTabClick` and resetting conflicting sort parameters (such as `timestamp` or `saleAmount`) when switching tabs. For sales analytics, a secondary toggle group allows users to switch between sales count (`sales`) and monetary amounts (`saleAmount`), formatting values via `currencyFormatter` or `nFormatter`.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:92-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L92-L113), [apps/web/ui/analytics/events/events-tabs.tsx:52-75](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L52-L75)
| Event Category | Default Sort Field | Number Formatting Option | Sources |
| :--- | :--- | :--- | :--- |
| `clicks` | `date` | Standard notation / compact if > 999,999 | [apps/web/ui/analytics/events/events-tabs.tsx:52-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L52-L55), [apps/web/ui/analytics/events/events-tabs.tsx:119-126](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L119-L126) |
| `leads` | `date` | Standard notation / compact if > 999,999 | [apps/web/ui/analytics/events/events-tabs.tsx:52-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L52-L55), [apps/web/ui/analytics/events/events-tabs.tsx:119-126](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L119-L126) |
| `sales` | `timestamp`, `saleAmount` | Currency (`USD`, strip trailing zeros if integer) | [apps/web/ui/analytics/events/events-tabs.tsx:52-55](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L52-L55), [apps/web/ui/analytics/events/events-tabs.tsx:111-118](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L111-L118) |
Sources: [apps/web/ui/analytics/events/events-tabs.tsx:52-126](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/events/events-tabs.tsx#L52-L126)
The chart container evaluates the current view setting (`timeseries` or `funnel`) alongside workspace plan entitlements (`free`, `pro`, `business`) to determine whether to render the `AnalyticsTimeseriesChart`, `AnalyticsAreaChart`, or `AnalyticsFunnelChart`. When free or pro workspaces attempt to view conversion events or funnel views, a paywall overlay masks the chart component and prompts the user to upgrade.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-chart.tsx:85-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-chart.tsx#L85-L91), [apps/web/ui/analytics/chart-section.tsx:69-116](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/chart-section.tsx#L69-L116)
```typescript
const TAB_COLOR: Record = {
clicks: "text-blue-500",
leads: "text-violet-600",
sales: "text-teal-400",
};
```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-timeseries-chart.tsx:7-11](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-timeseries-chart.tsx#L7-L11)
> [!NOTE]
> `AnalyticsTimeseriesChart` uses a composite React `key` built from `start`, `end`, `interval`, `selectedTab`, and `saleUnit` to force clean re-mounting when date filters or metric dimensions change.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-timeseries-chart.tsx:29-31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-timeseries-chart.tsx#L29-L31)
Interactive tooltips within `AnalyticsTimeseriesChart` pass tooltip date objects to `formatDateTooltip(d.date, { interval, start, end })`. The tooltip content layout renders a categorized breakdown row containing a color-coded indicator square matching the active tab (`text-blue-500`, `text-violet-600`, or `text-teal-400`) and the formatted metric value.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/analytics-timeseries-chart.tsx:40-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/analytics-timeseries-chart.tsx#L40-L67)
## Geographic and Device Breakdown Cards
The geographic, device, and partner segment breakdowns within the analytics dashboard are powered by dedicated section components (`LocationSection`, `DeviceSection`, and `PartnerSegmentsSection`) that render multi-tabbed cards encapsulating `BarList` visualizations. Each section retrieves analytics data via `useAnalyticsFilterOption` hooks, sorting items by count or sales value and synchronizing active filters directly to URL search parameters.
Sources: [apps/web/ui/analytics/location-section.tsx:19-32](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L19-L32), [apps/web/ui/analytics/device-section.tsx:14-25](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L14-L25), [apps/web/ui/analytics/partner-segments-section.tsx:56-140](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx#L56-L140)
The `LocationSection` manages four discrete tabs (`countries`, `cities`, `regions`, `continents`), mapping each tab identifier to its corresponding singular endpoint name using `SINGULAR_ANALYTICS_ENDPOINTS`. When rendering rows, it resolves country flags, continent display icons, and region labels—handling region codes ending in `-Unknown` by falling back to country names. Similarly, the `DeviceSection` provides tabs for `devices`, `browsers`, `os`, and `triggers`, utilizing `DeviceIcon` and `TRIGGER_DISPLAY` mappings to render icons and titles.
Sources: [apps/web/ui/analytics/location-section.tsx:1-119](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L1-L119), [apps/web/ui/analytics/device-section.tsx:1-102](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L1-L102)
| Section Component | Supported Tabs / Subtabs | Default Tab | Filter Query Parameter | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `LocationSection` | `countries`, `cities`, `regions`, `continents` | `countries` | `country`, `city`, `region`, `continent` | [apps/web/ui/analytics/location-section.tsx:25-33](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L25-L33), [apps/web/ui/analytics/location-section.tsx:74-79](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L74-L79) |
| `DeviceSection` | `devices`, `browsers`, `os`, `triggers` | `devices` | `device`, `browser`, `os`, `trigger` | [apps/web/ui/analytics/device-section.tsx:20-25](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L20-L25), [apps/web/ui/analytics/device-section.tsx:66-71](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L66-L71) |
| `PartnerSegmentsSection` | `segments` (`groups`, `tags`), `links` (`short_links`, `destination_urls`) | `segments` (`groups`) | `groupId`, `partnerTagId`, `linkId`, `url` | [apps/web/ui/analytics/partner-segments-section.tsx:14-54](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx#L14-L54) |
Sources: [apps/web/ui/analytics/location-section.tsx:25-79](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L25-L79), [apps/web/ui/analytics/device-section.tsx:20-71](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L20-L71), [apps/web/ui/analytics/partner-segments-section.tsx:14-54](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx#L14-L54)
User interactions with individual bar list items invoke toggle and apply handlers that update local selection states and modify URL query strings. The filter execution sequence follows a strict callback chain across components:
`onToggleFilter(val)` → updates local `selectedItems` array → `onApplyFilterValues(values)` → checks if `values.length === 0` to delete the parameter via `queryParams({ del: singularTabName })` or set comma-joined values via `queryParams({ set: { [singularTabName]: values.join(",") } })`.
Sources: [apps/web/ui/analytics/location-section.tsx:41-59](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L41-L59), [apps/web/ui/analytics/device-section.tsx:33-51](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L33-L51)
> [!WARNING]
> Switching between category tabs within `LocationSection`, `DeviceSection`, or `PartnerSegmentsSection` automatically triggers an `useEffect` hook that clears all currently selected filter items (`setSelectedItems([])`), preventing stale filter identifiers from bleeding across different metric dimensions.
Sources: [apps/web/ui/analytics/location-section.tsx:37-39](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/location-section.tsx#L37-L39), [apps/web/ui/analytics/device-section.tsx:29-31](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/device-section.tsx#L29-L31), [apps/web/ui/analytics/partner-segments-section.tsx:73-75](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx#L73-L75)
The `PartnerSegmentsSection` supports hierarchical grouping through two primary tabs (`segments` and `links`) and four nested subtabs (`groups`, `tags`, `short_links`, `destination_urls`). It resolves group color circles via `GroupColorCircle`, partner tags via `Tag`, and destination domains via `LinkLogo` using `getApexDomain`. When workspace link display preferences include title properties, short link rows prioritize rendering the link title over its short link slug.
Sources: [apps/web/ui/analytics/partner-segments-section.tsx:14-168](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/partner-segments-section.tsx#L14-L168)
## Program Applications and Revenue Analytics
Enterprise partner program analytics cover specialized operations such as tracking application conversion funnels, administering payouts and commission structures, and monitoring multi-tenant revenue metrics across administrative dashboards and workspace views. Querying these dimensions involves dedicated API endpoints, SWR hooks, and specialized visualization components.
Sources: [apps/web/app/ee/api/program-applications/analytics/route.ts:37-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L37-L40), [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:42-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L42-L48)
The program applications endpoint `GET /api/program-applications/analytics` resolves the workspace default program identifier through `getDefaultProgramIdOrThrow(workspace)` and validates query parameters against `applicationEventAnalyticsQuerySchema`. Depending on the requested `groupBy` parameter, the execution flow diverges into absolute counts, grouped property queries, partner breakdowns, or timeseries aggregations.
Sources: [apps/web/app/ee/api/program-applications/analytics/route.ts:38-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L38-L155)
The query execution chain follows a precise sequence:
`withWorkspace()` wrapper → `getDefaultProgramIdOrThrow()` → `applicationEventAnalyticsQuerySchema.parse()` → `getStartEndDates()` → `parseFilterValue()` → conditional branch on `groupBy` (`count` | `referralSource` | `country` | `partnerId` | `timeseries`).
Sources: [apps/web/app/ee/api/program-applications/analytics/route.ts:38-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L38-L155)
> [!WARNING]
> When `groupBy` is set to `partnerId`, the API executes a two-step query: first grouping program application events by `referredByPartnerId`, and then performing a secondary relational lookup in `prisma.partner.findMany` filtered by partners associated with the active `programId`.
Sources: [apps/web/app/ee/api/program-applications/analytics/route.ts:137-196](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/analytics/route.ts#L137-L196)
The `ApplicationsFunnelChart` component renders a four-stage conversion funnel mapping raw visits to final partner approvals. Each stage links to filtered query parameters and maps to specific event counts and color tokens.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-92](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L92)
| Stage ID | Metric Key | Color Token | Description | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `visited` | `visits` | `text-blue-500` | Raw visitor traffic landing on program pages | [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L40) |
| `started` | `starts` | `text-violet-600` | Applications initiated by prospective partners | [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L40) |
| `submitted` | `submissions` | `text-pink-500` | Completed application forms submitted for review | [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L40) |
| `approved` | `approvals` | `text-teal-400` | Successfully approved partner applications | [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L40) |
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/applications/applications-funnel-chart.tsx:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/applications/applications-funnel-chart.tsx#L25-L40)
Administrative consoles in `admin.dub.co` utilize dedicated page components to render financial timeseries, payout status metrics, and commission metrics. The `RevenuePageClient` component computes period-over-period percentage changes by evaluating rolling timestamp boundaries from `timeseries` and `previousPeriodTimeseries` datasets.
Sources: [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx:47-146](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx#L47-L146), [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx:83-133](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx#L83-L133)
| Administrative View | Primary SWR Endpoint | Key Metrics Tracked | Design Trade-Off / Behavior | Sources |
| :--- | :--- | :--- | :--- | :--- |
| **Revenue Dashboard** | `/api/admin/revenue` | `totalRevenue`, `mrr`, `payoutFees` | Annualizes final metrics dynamically using a 12-month multiplier based on rolling period bounds. | [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx:52-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx#L52-L61) |
| **Payouts Dashboard** | `/api/admin/payouts` | `invoices`, `timeseriesData`, `fees` | Extracts unique programs from invoice lists on the client side to build filter options. | [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx:90-153](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx#L90-L153) |
| **Commissions Dashboard** | `/api/admin/commissions` | `commissions`, `fees`, `programs` | Fetches filtered and unfiltered program scopes concurrently to populate filter dropdowns and charts. | [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx:46-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx#L46-L68) |
Sources: [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx:52-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx#L52-L61), [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx:90-153](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx#L90-L153), [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx:46-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx#L46-L68)
> [!TIP]
> Payout fees displayed in the revenue administration view are computed based on a trailing 6-month rolling average.
Sources: [apps/web/app/ee/admin.dub.co/dashboard/revenue/page.tsx:26-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx#L26-L28)
## Related
- [[Tinybird Analytics Engine]]
- [[Data Exports]]
---
## Technical docs: PATCH Update partner network status
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/adminupdatepartnernetworkstatus
## Parameters
## Request Body
New network status
## Responses
## Try It
---
## Technical docs: Data Exports
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-and-tracking/data-exports
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts)
- [apps/web/app/ee/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts)
- [apps/web/app/ee/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts)
- [apps/web/app/api/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts)
- [apps/web/app/ee/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts)
- [apps/web/app/ee/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts)
- [apps/web/app/ee/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts)
- [apps/web/app/ee/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts)
- [apps/web/app/ee/api/payouts/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/payouts/export/route.ts)
- [apps/web/app/ee/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts)
- [apps/web/app/ee/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts)
- [apps/web/app/ee/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/customers/export/route.ts)
- [apps/web/app/api/links/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/app/ee/api/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/customers/export/route.ts)
- [apps/web/app/ee/api/cron/import/tapfiliate/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts)
- [apps/web/app/ee/api/audit-logs/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts)
- [apps/web/app/ee/api/partners/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/export/route.ts)
- [apps/web/app/ee/api/cron/import/rewardful/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts)
- [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts)
- [apps/web/app/ee/api/cron/aggregate-clicks/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts)
- [apps/web/scripts/programs/5-import-customer-sales.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/5-import-customer-sales.ts)
- [apps/web/lib/analytics/export-analytics-to-zip.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/export-analytics-to-zip.ts)
- [apps/web/app/ee/api/program-applications/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/export/route.ts)
- [apps/web/app/ee/api/cron/streams/update-click-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts)
- [apps/web/app/ee/api/admin/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/payouts/route.ts)
- [apps/web/ui/analytics/analytics-export-button.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-export-button.tsx)
## Overview
Data Exports enables workspaces and partners to extract large volumes of operational metrics, analytics, and partnership data into structured formats like CSV spreadsheets and ZIP archives. By offering both immediate synchronous responses for small datasets and QStash-driven asynchronous background pipelines for large workloads, the system prevents request timeouts while securely delivering generated artifacts via signed storage URLs and email notifications. Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L12-L41), [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L83-L101), [apps/web/app/api/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L109-L128)
## Synchronous Versus Background Export Architecture
### Synchronous Versus Background Export Architecture
### Overview
The export system balances request responsiveness with computational safety by routing requests through either a direct synchronous stream or an asynchronous QStash worker pipeline. When client requests query small datasets or request compressed ZIP archives like analytics exports, endpoints execute within standard HTTP timeouts and return binaries directly. When query thresholds exceed predefined volume limits—such as over 1,000 links or commissions—endpoints offload processing to background QStash workers, returning an immediate `202 Accepted` status.
Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L12-L41), [apps/web/app/api/analytics/export/route.ts:21-128](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L21-L128), [apps/web/app/api/links/export/route.ts:19-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L19-L60)
### Execution Pathway Comparison
The execution path diverges based on data volume checks executed prior to export generation. For links and commissions, endpoints query row counts using `getLinksCount` and `getCommissionsCount`, comparing totals against `MAX_LINKS_TO_EXPORT` (1,000) and `MAX_COMMISSIONS_TO_EXPORT` (1,000).
```mermaid
graph TD
A[Client Request] --> B{Count > Threshold?}
B -- Yes --> C[Publish QStash Job]
C --> D[Return 202 Accepted]
B -- No --> E[Fetch Batch / Stream CSV]
E --> F[Return Direct Response]
```
Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L23-L62), [apps/web/app/api/links/export/route.ts:21-92](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L21-L92)
### Architectural Trade-Offs
| Approach | Benefit | Cost |
| :--- | :--- | :--- |
| Synchronous CSV / ZIP (`GET`) | Immediate file download in browser; simpler request-response lifecycle without external queues. | Subject to Vercel/HTTP timeout limits (e.g., `maxDuration = 300` for analytics); risks memory exhaustion on large result sets. |
| Asynchronous QStash Worker (`POST`) | Handles unbounded datasets via chunked batch iteration; offloads heavy CPU/IO processing from HTTP request threads. | Requires webhook signature verification (`verifyQstashSignature`), storage upload management (`createDownloadableExport`), and email dispatch (`sendEmail`). |
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L23-L101), [apps/web/app/api/analytics/export/route.ts:21-128](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L21-L128), [apps/web/app/api/links/export/route.ts:48-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L48-L60)
> [!NOTE]
> Analytics exports (`/api/analytics/export`) bypass the QStash threshold check entirely, utilizing an extended route segment config `maxDuration = 300` to stream zipped multi-endpoint analytics payloads directly within the HTTP connection.
> Sources: [apps/web/app/api/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L21-L128)
> [!WARNING]
> Background export workers authenticate incoming QStash payloads via `verifyQstashSignature` or `withCron`, rejecting unverified HTTP posts before parsing payload zulu schemas or querying user records.
> Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L25-L30), [apps/web/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts#L18-L22)
## Synchronous Workspace CSV Endpoints
### Synchronous Workspace CSV Endpoints
### Overview
Workspace-authenticated synchronous CSV endpoints handle direct file downloads for links, commissions, payouts, customers, audit logs, and program applications. Each route validates workspace context and permissions using `withWorkspace`, parses query parameters with dedicated Zod schemas, checks volume limits, and either returns a direct CSV response (`200 OK`) or offloads execution to QStash background cron jobs (`202 Accepted`) when dataset sizes exceed defined thresholds.
Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L15-L63), [apps/web/app/(ee)/api/payouts/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/payouts/export/route.ts#L14-L59), [apps/web/app/api/links/export/route.ts:22-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L22-L96), [apps/web/app/(ee)/api/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app/(ee)/api/customers/export/route.ts#L16-L72), [apps/web/app/(ee)/api/audit-logs/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L16-L60), [apps/web/app/(ee)/api/program-applications/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/export/route.ts#L24-L93)
### Endpoint Reference and Constraints
The synchronous export routes enforce strict access controls, plan restrictions, and volume limits before querying or formatting records.
| Route Path | Method | Max Synchronous Limit | Plan / Permission Requirements | QStash Offload URL |
| :--- | :--- | :--- | :--- | :--- |
| `/api/links/export` | `GET` | 1,000 links | `links.read` permission | `/api/cron/export/links` |
| `/api/commissions/export` | `GET` | 1,000 commissions | Default program ID | `/api/cron/export/commissions` |
| `/api/payouts/export` | `GET` | 1,000 payouts | Default program ID | `/api/cron/export/payouts` |
| `/api/customers/export` | `GET` | 1,000 customers | `business`, `advanced`, `enterprise` | `/api/cron/export/customers` |
| `/api/audit-logs/export` | `POST` | None (All queried) | `enterprise`, roles `owner`/`member` | None (Direct only) |
| `/api/program-applications/export` | `GET` | None (All queried) | `business`, `advanced`, `enterprise` | None (Direct only) |
Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L12-L41), [apps/web/app/(ee)/api/payouts/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/payouts/export/route.ts#L11-L39), [apps/web/app/api/links/export/route.ts:19-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L19-L60), [apps/web/app/(ee)/api/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app/(ee)/api/customers/export/route.ts#L13-L49), [apps/web/app/(ee)/api/audit-logs/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L16-L60), [apps/web/app/(ee)/api/program-applications/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/export/route.ts#L24-L93)
### Call-Chain Execution Walkthrough
For workspace link exports, the synchronous request execution follows a precise validation and query sequence:
`withWorkspace()` -> `throwIfClicksUsageExceeded(workspace)` -> `linksExportQuerySchema.parse(searchParams)` -> `validateLinksQueryFilters()` -> `getStartEndDates()` -> `getLinksCount()` -> Threshold check (`linksCount > 1000`? If yes: `qstash.publishJSON()` -> `202 Accepted`) -> `getLinksForWorkspace()` -> `formatLinksForExport()` -> `convertToCSV()` -> `new Response(csvData)` with headers.
Sources: [apps/web/app/api/links/export/route.ts:22-92](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L22-L92)
### Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Fixed synchronous export threshold (`MAX_*_TO_EXPORT = 1000`) | Protects server memory and request timeout limits by redirecting large reports to background workers. | Datasets slightly above 1,000 records require users to wait for asynchronous email delivery instead of immediate download. |
| Workspace permission wrappers (`withWorkspace`) | Centralizes tenant isolation, session extraction, and permission checks (`links.read`, plan checks). | Couples route handlers tightly to authentication middleware wrappers. |
| Dynamic schema column ordering (e.g. Program Applications) | Automatically aligns exported CSV headers with defined UI column configuration and sort maps. | Adds CPU overhead to map, sort, and parse record keys on every export request. |
Sources: [apps/web/app/(ee)/api/commissions/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/export/route.ts#L12-L41), [apps/web/app/api/links/export/route.ts:22-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L22-L96), [apps/web/app/(ee)/api/program-applications/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/export/route.ts#L58-L81)
> [!WARNING]
> Link exports automatically tighten query parameters for large workspaces: when workspace total links exceed `SORTABLE_LINKS_LIMIT`, `sortBy` forces to `"createdAt"`, and when total links exceed `MEGA_WORKSPACE_LINKS_LIMIT`, `searchMode` forces to `"exact"`.
> Sources: [apps/web/app/api/links/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/export/route.ts#L68-L73)
> [!NOTE]
> Audit log exports (`/api/audit-logs/export`) use an HTTP `POST` method requiring a JSON body with `start` and `end` date strings, explicitly validating plan capabilities via `getPlanCapabilities(workspace.plan)` before querying records.
> Sources: [apps/web/app/(ee)/api/audit-logs/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L16-L36)
## Asynchronous Cron and Worker Pipelines
### Overview
Large-scale background data exports are driven by asynchronous QStash cron worker endpoints located under `apps/web/app/(ee)/api/cron/export/`. These workers process massive data volumes—such as commissions, payouts, links, customers, and partners—by fetching records in memory-safe asynchronous batches, converting them to CSV format, storing them as downloadable artifacts, and notifying users via email upon completion.
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L22-L105), [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L21-L111), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L27-L147), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L18-L98), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L22-L105)
### QStash Verification and Request Pipeline
The asynchronous worker endpoints utilize two distinct middleware mechanisms for authentication and payload verification: manual signature validation via `verifyQstashSignature` or wrapper-based execution via `withCron`.
For manual signature verification routes (commissions, links, and partners), the incoming request text is read and verified against QStash headers before JSON payload parsing and Zod schema validation. Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L25-L34), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L30-L39), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L25-L34)
Routes configured with `withCron` (payouts and customers) handle signature verification and request lifecycle wrapping implicitly. Sources: [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L21-L22), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L18-L19)
### Batch Processing and Execution Flow
To prevent memory exhaustion during large-scale data retrieval, workers iterate over asynchronous generators that fetch records in controlled batches.
| Export Endpoint | Batch Fetcher Function | Formatting Function | Hard Record Limit |
| :--- | :--- | :--- | :--- |
| `/api/cron/export/commissions` | `fetchCommissionsBatch()` | `formatCommissionsForExport()` | None (Full query) |
| `/api/cron/export/payouts` | `fetchPayoutsBatch()` | `formatPayoutsForExport()` | `100_000` |
| `/api/cron/export/links` | `fetchLinksBatch()` | `formatLinksForExport()` | None (Full query) |
| `/api/cron/export/customers` | `fetchCustomersBatch()` | `formatCustomersForExport()` | `100_000` |
| `/api/cron/export/partners` | `fetchPartnersBatch()` | `formatPartnersForExport()` | None (Full query) |
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L75-L79), [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L17-L80), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L119-L121), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L14-L67), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L71-L79)
> [!NOTE]
> Payouts and customers worker pipelines enforce an explicit upper export bound (`MAX_PAYOUTS_EXPORT_LIMIT` and `MAX_CUSTOMERS_EXPORT_LIMIT` set to `100,000` rows), breaking the batch consumption loop once the accumulator reaches capacity.
> Sources: [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L17-L77), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L14-L64)
> [!WARNING]
> Link exports automatically adjust `searchMode` during filter construction when `workspace.totalLinks` exceeds `MEGA_WORKSPACE_LINKS_LIMIT`, forcing exact searches instead of fuzzy matching for performance stability.
> Sources: [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L109-L111)
## ZIP Archive Packaging for Analytics
### Overview
Analytics exports bundle multi-endpoint analytics datasets into a downloadable ZIP archive. The pipeline queries multiple analytics groupings concurrently or sequentially, transforms response rows into CSV format, packages them via `jszip`, and returns a buffered node stream with `application/zip` headers. The primary route handler executes at `/api/analytics/export`, backed by workspace authentication, rate limiting policies (`RATELIMIT_POLICIES.analyticsExport`), and plan capability validations. A partner-profile counterpart located at `/api/partner-profile/programs/[programId]/analytics/export` applies partner-specific enrollment checks, rate limiting (`RATELIMIT_POLICIES.partnerAnalyticsExport`), and row formatters.
Sources: [apps/web/app/api/analytics/export/route.ts:18-132](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L18-L132), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts#L26-L136), [apps/web/lib/analytics/export-analytics-to-zip.ts:52-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/export-analytics-to-zip.ts#L52-L93)
### Multi-Endpoint Aggregation and Call-Chain Execution
The export workflow orchestrates parameter normalization, analytics retrieval across grouped endpoints, row formatting, and ZIP file generation.
The execution walkthrough follows this exact call chain:
1. `GET` route handler receives request parameters, enforces rate limits, checks click usage, parses queries with `parseAnalyticsQuery` or `partnerProfileAnalyticsQuerySchema`, and verifies workspace or program folders.
2. `exportAnalyticsToZip()` initializes an instance of `JSZip` and calls `getAnalyticsExportEndpoints()` to determine which analytics dimensions to query.
3. Iterating over each resulting endpoint, `getAnalytics()` fetches data with query filters, composite event configurations, and custom date range overrides.
4. If rows are returned, `formatRows()` transforms them if a formatter is provided (such as `formatProgramAnalyticsForExport` or `formatPartnerAnalyticsForExport`).
5. `convertToCSV()` transforms the record array into a CSV string.
6. `zip.file(`${endpoint}.csv`, ...)` appends the CSV to the archive, and `zip.generateAsync({ type: "nodebuffer" })` resolves the final buffer returned to the HTTP response.
Sources: [apps/web/app/api/analytics/export/route.ts:33-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L33-L121), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts#L61-L128), [apps/web/lib/analytics/export-analytics-to-zip.ts:52-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/export-analytics-to-zip.ts#L52-L93)
> [!NOTE]
> The `maxDuration` exported constant on both API route files is explicitly configured to `300` seconds to accommodate large multi-endpoint query aggregation jobs.
> Sources: [apps/web/app/api/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/analytics/export/route.ts#L21), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/analytics/export/route.ts#L23)
### Endpoint Configuration and Exclusions
The packaging logic filters default analytics endpoints by omitting specified exclusions or skipping single-link top link breakdowns. Partner profile endpoints exclude broader partner metadata dimensions.
| Constant Name | Value / Members | Purpose |
| :--- | :--- | :--- |
| `DEFAULT_SKIP_ENDPOINTS` | `["count"]` | Excludes the basic count endpoint from standard analytics ZIP archives. |
| `PARTNER_PROFILE_SKIP_ENDPOINTS` | `["count", "top_partners", "top_groups", "top_partner_tags", "top_folders", "top_link_tags"]` | Excludes non-applicable dimensions when exporting partner profile analytics. |
Sources: [apps/web/lib/analytics/export-analytics-to-zip.ts:10-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/export-analytics-to-zip.ts#L10-L19)
### Client-Side Trigger and Blob Handling
The client interacts with the export endpoints via `AnalyticsExportButton`, which invokes the dynamic route based on whether a partner page context is active, triggers a toast promise, handles blob response creation, and programmatically initiates file downloads. Sources: [apps/web/ui/analytics/analytics-export-button.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/analytics-export-button.tsx#L7-L76)
## Click and Event Log Exports
### Overview
Click and event log exports handle filtering, pagination, and column projection across Tinybird event logs and raw click streams. The system supports both synchronous responses for smaller datasets and asynchronous background tasks via QStash when event counts exceed designated thresholds.
Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L38-L187), [apps/web/app/(ee)/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts#L36-L168), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L35-L203), [apps/web/app/(ee)/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts#L30-L91)
### Threshold Control and Pipeline Execution
The event export route begins by validating plan requirements and querying analytics counts. If the matched events exceed `MAX_EVENTS_TO_EXPORT`, the job dispatches a background task to QStash and immediately returns an HTTP 202 response. Otherwise, it retrieves the event stream synchronously, maps requested columns using accessors, and outputs a downloadable CSV.
```mermaid
graph TD
A[Client Request] --> B{Count > MAX_EVENTS_TO_EXPORT?}
B -->|Yes| C[Publish QStash Background Job]
C --> D[Return HTTP 202 Accepted]
B -->|No| E[Fetch Events via getEvents]
E --> F[Map Columns & Format CSV]
F --> G[Return CSV Response]
```
Sources: [apps/web/app/(ee)/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts#L36-L175), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L35-L203)
### Configuration Constants and Default Columns
Event export behavior is regulated by strict limits and preset column mappings for different event types.
| Constant Name | Value / Type | Purpose |
| :--- | :--- | :--- |
| `MAX_EVENTS_TO_EXPORT` | `1000` | Threshold that determines whether to process an event export synchronously or offload to background QStash workers. |
| `MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING` | Constant from `@dub/constants` | Maximum number of partner links allowed before switching from explicit local link evaluation to partner-level query filters. |
| `defaultColumns["clicks"]` | `["timestamp", "link", "referer", "country", "device"]` | Default projected columns when exporting click events without an explicit column parameter. |
| `defaultColumns["leads"]` | `["timestamp", "event", "link", "customer", "referer"]` | Default projected columns when exporting lead events. |
| `defaultColumns["sales"]` | `["timestamp", "saleAmount", "event", "customer", "referer", "link"]` | Default projected columns when exporting sale events. |
Sources: [apps/web/app/(ee)/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts#L25-L34), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L14-L17), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L80-L91)
> [!WARNING]
> Requests exceeding `1000` events bypass synchronous payload rendering entirely. They publish JSON payloads to QStash endpoints (`/api/cron/export/events/workspace` or `/api/cron/export/events/partner`) and return HTTP 202 status codes, requiring clients to poll or retrieve results via generated email notifications.
> Sources: [apps/web/app/(ee)/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts#L136-L149), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L173-L190)
### Column Projection and Transformation
When formatting event log rows, the system evaluates dynamic column accessors and column names via helper mappings, falling back to direct property access and capitalized keys. Partner-specific export workers enforce strict sanitization rules, dropping IP addresses from event payloads and obfuscating customer email fields unless explicit data sharing consent is active. Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L176-L183), [apps/web/app/(ee)/api/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/events/export/route.ts#L159-L166), [apps/web/app/(ee)/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts#L82-L89)
## Partner Program Exports and Privacy
### Overview
Partner program data exports provide specialized routes for partners to export events and customer records associated with specific programs. These endpoints enforce strict program enrollment verification, check minimum commission thresholds for large programs, and implement privacy safeguards such as customer email obfuscation and pseudorandom name generation when data sharing is disabled.
Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L68-L76), [apps/web/app/(ee)/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts#L56-L63), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L38-L57), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/customers/export/route.ts#L26-L44)
### Enrollment Checks and Threshold Rules
Before processing any partner export, the request pipeline validates partner enrollment and enforces tier-based restrictions. Large programs require a minimum total commission amount in cents before export features are enabled.
| Constant Name | Value / Type | Purpose |
| :--- | :--- | :--- |
| `MAX_EVENTS_TO_EXPORT` | `1000` | Maximum event count for synchronous partner event exports before offloading via QStash. |
| `MAX_CUSTOMERS_TO_EXPORT` | `1000` | Maximum customer count for synchronous partner customer exports before background processing. |
| `MAX_CUSTOMERS_EXPORT_LIMIT` | `100_000` | Hard cap on total customer records processed by the background customer export worker. |
| `PAGE_SIZE` | `1000` | Chunk size used when paginating database queries in background customer export loops. |
Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L38-L49), [apps/web/app/(ee)/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts#L16-L17), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L33-L37), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/customers/export/route.ts#L18-L18)
> [!WARNING]
> If a partner belongs to a program included in `LARGE_PROGRAM_IDS`, the total commissions converted to cents via `toCentsNumber(totalCommissions)` must meet or exceed `LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS`. Otherwise, the API throws a `forbidden` `DubApiError`.
> Sources: [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L48-L57), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/customers/export/route.ts#L35-L44)
### Customer Email Obfuscation and Aliasing
Partner event and customer exports protect end-user privacy when customer data sharing is disabled. IP addresses are systematically stripped from both click and event payloads. Customer email addresses are obfuscated using `obfuscateCustomerEmail`, and missing names or obfuscated records fall back to pseudorandomly generated names via `generateRandomName`. Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L144-L171), [apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/events/export/route.ts#L205-L225)
> [!TIP]
> When `customerDataSharingEnabledAt` is present and active, raw customer emails and real customer names are preserved in the export. When absent, email addresses are masked and names default to generated pseudorandom identifiers.
> Sources: [apps/web/app/(ee)/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts#L154-L171), [apps/web/app/(ee)/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts#L56-L80)
## Downloadable Artifact Storage and Delivery
### Overview
Once large datasets for commissions, payouts, customers, links, partners, and workspace events are gathered and formatted into CSV strings, the background worker pipeline stores the resulting file and notifies the user via email. This process coordinates signature verification, file key generation, signed storage uploads, and React-based email templates.
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L83-L101), [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L84-L102), [apps/web/app/(ee)/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts#L127-L145), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L125-L143), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L71-L89), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L83-L101), [apps/web/app/(ee)/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts#L95-L113)
### Export Delivery Call Chain and Storage Paths
Background export handlers follow a structured delivery sequence from raw body ingestion to recipient notification.
`verifyQstashSignature()` -> `prisma.user.findUnique()` -> `createDownloadableExport()` -> `sendEmail()` -> `ExportReady()`
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L27-L101), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L32-L143), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L27-L101)
Storage keys are generated using randomized suffixes combined with entity-specific subdirectories via `generateRandomString(16)` and `generateExportFilename()`.
| Export Domain | File Key Template | Content Type |
| :--- | :--- | :--- |
| Commissions | `exports/commissions/${generateRandomString(16)}.csv` | `text/csv` |
| Payouts | `exports/payouts/${generateRandomString(16)}.csv` | `text/csv` |
| Partner Customers | `exports/customers/partner/${generateRandomString(16)}.csv` | `text/csv` |
| Links | `exports/links/${generateRandomString(16)}.csv` | `text/csv` |
| Customers | `exports/customers/${generateRandomString(16)}.csv` | `text/csv` |
| Partners | `exports/partners/${generateRandomString(16)}.csv` | `text/csv` |
| Workspace Events | `exports/events/workspace/${generateRandomString(16)}.csv` | `text/csv` |
Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L83-L88), [apps/web/app/(ee)/api/cron/export/payouts/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/payouts/route.ts#L84-L89), [apps/web/app/(ee)/api/cron/export/customers/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/partner/route.ts#L127-L132), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L125-L130), [apps/web/app/(ee)/app/(ee)/api/cron/export/customers/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/customers/route.ts#L71-L76), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L83-L88), [apps/web/app/(ee)/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts#L95-L100)
> [!WARNING]
> If the target user record cannot be found or lacks an associated email address during export processing, the execution terminates early and logs a skip message rather than throwing an unhandled exception.
> Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L45-L51), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L50-L56), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L45-L51)
### Email Notification Structure
Once `createDownloadableExport` returns the `downloadUrl`, the worker dispatches a notification using `@dub/email` with the `ExportReady` React template. The payload passes the recipient email, export type identifier, download URL, and contextual workspace or program metadata. Sources: [apps/web/app/(ee)/api/cron/export/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/commissions/route.ts#L90-L101), [apps/web/app/(ee)/api/cron/export/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/links/route.ts#L132-L143), [apps/web/app/(ee)/api/cron/export/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/partners/route.ts#L90-L101)
## Related
- [[Analytics Dashboard and Querying]]
- [[Background Jobs and Queues]]
---
## Technical docs: POST Add or update a partner platform
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/adminupsertpartnerplatform
## Parameters
## Request Body
Platform details and identifier
## Responses
## Try It
---
## Technical docs: GET Get admin partner details
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadminpartner
## Parameters
## Responses
## Try It
---
## Technical docs: Partner Program Management
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-program-management
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/groups/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/groups/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/apply/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/apply/page.tsx)
- [apps/web/app/ee/api/partners/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts)
- [apps/web/lib/actions/partners/create-program.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/partners/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/page.tsx)
- [apps/web/app/ee/api/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/partners/trusted/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/trusted/page.tsx)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/app/ee/partners.dub.co/apply/programSlug/default/apply/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/apply/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/partnerId/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/%5BpartnerId%5D/layout.tsx)
- [apps/web/lib/actions/partners/approve-program-application.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/approve-program-application.ts)
- [apps/web/ui/layout/sidebar/app-sidebar-nav.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx)
- [apps/web/lib/middleware/partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts)
- [apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/partners/network/network-partner-application-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/network-partner-application-sheet.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/layout.tsx)
- [apps/web/ui/layout/sidebar/dub-partners-popup.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/dub-partners-popup.tsx)
- [apps/web/app/ee/partners.dub.co/apply/programSlug/default/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page.tsx)
- [apps/web/app/ee/api/program-applications/approve/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/approve/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/page-client.tsx)
- [apps/web/lib/actions/partners/create-program-application.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/program-empty-state.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/program-empty-state.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/auth.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/auth.tsx)
- [apps/web/ui/layout/sidebar/partner-program-dropdown.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx)
- [apps/web/scripts/dev/seed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/seed.ts)
- [apps/web/app/ee/api/partner-profile/programs/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/applications-nav.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/applications-nav.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/page.tsx)
## Overview
Partner Program Management provides a comprehensive engine for orchestrating affiliate and referral ecosystems within workspaces, empowering organizations to scale product-led growth through structured partner participation. It addresses the complexity of multi-tenant tracking, partner recruitment, custom segmentation, and tiered reward structures by bridging workspace administration with public application portals and automated workflows. Core design decisions prioritize modular group architectures, granular link attribution with reward overrides, and secure submission pipelines equipped with fraud detection capabilities. The system integrates closely with workspace authentication guards, plan entitlement checks, navigation sidebars, and specialized subdomain middleware to route partners and administrators across dedicated portal environments seamlessly. Sources: [apps/web/lib/actions/partners/create-program.ts:34-241](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L34-L241), [apps/web/ui/layout/sidebar/app-sidebar-nav.tsx:90-124](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx#L90-L124), [apps/web/lib/middleware/partners.ts:25-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L25-L122)
## Program Initialization and Workspace Access
### Overview
Workspace program initialization gates access to partner features via plan entitlement validation, explicit user session permission checks, and programmatic onboarding creation steps. Before a partner program can be initialized or administered, the workspace must satisfy specific plan capabilities and possess an active onboarding store state.
Sources: [apps/web/lib/actions/partners/create-program.ts:34-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L34-L74), [apps/web/app/app.dub.co/dashboard/slug/ee/program/auth.tsx:1-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/auth.tsx#L1-L60)
### Plan Entitlement and Access Control
The authorization boundary for partner programs relies on evaluating the workspace plan capabilities and checking user-level access permissions. The `ProgramAuth` component and `createProgram` action inspect plan parameters using `getPlanCapabilities` and `isLegacyBusinessPlan` to verify whether a workspace is permitted to manage a partner program.
```typescript
const { canManageProgram, canMessagePartners } = getPlanCapabilities(
workspace.plan,
);
if (
!canManageProgram ||
isLegacyBusinessPlan({
plan: workspace.plan,
partnersLimit: workspace.partnersLimit,
})
) {
throw new Error(
"Your current plan does not have access to create a partner program. Please upgrade to a higher plan to proceed.",
);
}
```
Sources: [apps/web/lib/actions/partners/create-program.ts:55-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L55-L69), [apps/web/app/app.dub.co/dashboard/slug/ee/program/auth.tsx:37-57](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/auth.tsx#L37-L57)
> [!WARNING]
> Workspaces flagged under a legacy business plan configuration or lacking program management capabilities will trigger an immediate exception during creation or render the `ProgramEmptyState` view, blocking access to partner dashboards.
Sources: [apps/web/lib/actions/partners/create-program.ts:59-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L59-L69), [apps/web/app/app.dub.co/dashboard/slug/ee/program/auth.tsx:47-57](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/auth.tsx#L47-L57)
### Onboarding Initialization Call Chain
When a user submits program onboarding data, the `createProgram` action executes a strict sequence of operations inside a transactional database boundary. It validates the domain, processes optional logo storage uploads, provisions folders, creates the program record, seeds default groups, and updates workspace settings.
```mermaid
graph TD
A[programDataSchema.parse] --> B[getDomainOrThrow]
B --> C[storage.upload]
C --> D[prisma.$transaction]
D --> E[tx.folder.upsert]
E --> F[tx.program.create]
F --> G[tx.partnerGroup.upsert]
G --> H[tx.project.update]
```
Sources: [apps/web/lib/actions/partners/create-program.ts:76-238](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L76-L238)
The execution steps follow a precise transactional ordering:
1. `programDataSchema.parse(store.programOnboarding)` extracts and validates parameters including name, domain, URL, reward type, and amounts.
2. `getDomainOrThrow({ workspace, domain })` validates that the workspace owns or can use the target domain.
3. `storage.upload` uploads an optional program logo using a generated storage key prefixed with `programs/{programId}/`.
4. `prisma.$transaction` wraps folder creation, program instantiation, default group seeding, and project metadata updates.
Sources: [apps/web/lib/actions/partners/create-program.ts:76-240](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L76-L240)
### Workspace Initialization Parameters
| Parameter | Type / Source | Purpose |
| :--- | :--- | :--- |
| `workspace` | `Pick` | Workspace context supplying id, slug, plan, and limits. |
| `user` | `Pick` | Authenticated user initializing the program. |
| `isProgramOnboarding` | `boolean` | Optional flag indicating initial setup flow context. |
| `store.programOnboarding` | `Record` | JSON store blob containing form inputs parsed by `programDataSchema`. |
Sources: [apps/web/lib/actions/partners/create-program.ts:34-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L34-L74)
> [!TIP]
> If a workspace lacks an `invoicePrefix` during program creation, the initialization sequence automatically generates an 8-character random string to ensure billing record consistency.
Sources: [apps/web/lib/actions/partners/create-program.ts:233-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program.ts#L233-L236)
## Partner Group Architecture and Attribution
### Overview
Partner groups segment participants by rewards, discounts, performance metrics, location, and customized criteria within a program. When requests are made for partner lander pages, application forms, or link generation, the system resolves specific group slugs—defaulting to `DEFAULT_PARTNER_GROUP.slug` if none is explicitly specified.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/groups/page.tsx:9-14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/groups/page.tsx#L9-L14), [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/apply/page.tsx:22-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/apply/page.tsx#L22-L25), [apps/web/app/ee/partners.dub.co/apply/programSlug/default/apply/page.tsx:18-24](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/apply/page.tsx#L18-L24), [apps/web/app/ee/partners.dub.co/apply/programSlug/default/page.tsx:18-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/page.tsx#L18-L23)
### UTM and Reward Configuration Call Chain
When generating tracking links for partners via the API route, the system executes a precise retrieval and application sequence to assign default URLs, tracking properties, UTM parameters, and reward overrides.
```mermaid
graph TD
A[createPartnerLinkSchemaInternal.parse] --> B[getProgramOrThrow]
B --> C[prisma.programEnrollment.findUnique]
C --> D[processLink]
D --> E[applyGroupUtmToLink]
E --> F[pickDefinedRewardIds]
F --> G[throwIfInvalidRewards]
G --> H[createLink]
```
Sources: [apps/web/app/ee/api/partners/links/route.ts:98-225](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L98-L225)
The link creation process follows these sequential execution steps:
1. `createPartnerLinkSchemaInternal.parse` validates incoming payload parameters including `partnerId`, `tenantId`, `url`, `key`, `linkProps`, and reward IDs.
2. `getProgramOrThrow` verifies that the program exists and possesses a configured `domain` and `url`.
3. `prisma.programEnrollment.findUnique` queries the database for the partner, extracting their associated `partnerGroup`, `partnerGroupDefaultLinks`, and `utmTemplate`.
4. `processLink` constructs the base link payload with target URLs falling back to `partnerGroup.partnerGroupDefaultLinks[0].url` if omitted.
5. `applyGroupUtmToLink` injects group-level UTM templates and partner identifiers into the link configuration.
6. `throwIfInvalidRewards` validates any specified link-level reward assignments against the target partner group.
7. `createLink` persists the fully configured partner link.
Sources: [apps/web/app/ee/api/partners/links/route.ts:98-225](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L98-L225)
### API Endpoints and Parameters for Partner Links
| Endpoint Method & Path | Validation Schema | Purpose |
| :--- | :--- | :--- |
| `GET /api/partners/links` | `retrievePartnerLinksSchemaInternal` | Retrieves existing tracking links for an enrolled partner, optionally including reward associations. |
| `POST /api/partners/links` | `createPartnerLinkSchemaInternal` | Generates a new tracking link for a partner, applying group UTM templates, default URLs, and optional reward overrides. |
Sources: [apps/web/app/ee/api/partners/links/route.ts:33-226](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L33-L226)
> [!CAUTION]
> If a partner is not associated with a valid partner group (`!partnerGroup`), the link creation request immediately aborts with a `not_found` API error preventing orphan records.
Sources: [apps/web/app/ee/api/partners/links/route.ts:155-161](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L155-L161)
## Public Application Form and Submission
### Public Application Form Configuration and Lander Resolution
Partner programs expose public landing pages and application forms resolved dynamically by `programSlug` and optional `groupSlug`. When visitors hit `ApplyPage`, the system fetches the program and group metadata using `getProgram`. If the lander data or application form data is unconfigured or unpublished, requests for the default group either redirect to the marketplace program page or return a `404` error.
Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/apply/page.tsx:12-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/apply/page.tsx#L12-L44), [apps/web/app/ee/partners.dub.co/apply/programSlug/default/page.tsx:13-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/page.tsx#L13-L43)
```mermaid
graph TD
A[Incoming Lander / Apply Request] --> B[getProgram]
B --> C{Program & Group Published?}
C -- Yes --> D[Parse Schema via Zod]
C -- No (Default Group) --> E{Marketplace Active?}
E -- Yes --> F[Redirect to /marketplace/programSlug]
E -- No --> G["Throw notFound()"]
C -- No (Custom Group) --> H[Redirect to Default Group Variant]
D --> I[Render LanderHero / ApplicationFormHero]
I --> J[Render Rewards, Bounties, & Blocks]
```
Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/apply/page.tsx:21-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/apply/page.tsx#L21-L44), [apps/web/app/ee/partners.dub.co/apply/programSlug/default/page.tsx:20-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/page.tsx#L20-L43)
### Application Submission Call Chain and Ingestion
The `createProgramApplicationAction` server action handles public form submissions and in-app applications. It enforces rate limits, validates payloads, processes existing user sessions, and executes database mutations.
```mermaid
graph TD
A[createProgramApplicationAction] --> B[assertRateLimit: 3 req/min/IP]
B --> C[prisma.program.findUniqueOrThrow]
C --> D{Existing Partner Session?}
D -- Yes --> E{In-App Application?}
E -- Yes --> F[Check Checklist Progress & Network Status]
F --> G[createApplicationAndEnrollment]
D -- No --> H[createApplication]
H --> I[programApplicationReminderJob.dispatch: 15m delay]
```
Sources: [apps/web/lib/actions/partners/create-program-application.ts:121-241](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L121-L241)
The step-by-step ingestion and execution flow proceeds as follows:
1. `assertRateLimit` restricts submissions to 3 requests per minute per program per IP using Upstash policies.
2. `prisma.program.findUniqueOrThrow` retrieves program relations, group configurations, and workspace webhook settings.
3. `getSession` identifies whether an authenticated partner session exists.
4. If an existing partner applies via `createApplicationAndEnrollment`, the system validates profile completeness checklist progress via `getNetworkProfileChecklistProgress` and ensures network status is `approved` or `trusted`.
5. If requirements fail during an external application, `autoRejectPartnerJob` is dispatched with a 30-minute delay.
6. For anonymous visitors, `createApplication` persists the application, records country headers (falling back to `"US"` in local dev/CI environments), sets a 7-day `programApplicationIds` HTTP-only cookie, and updates associated `programApplicationEvent` records.
Sources: [apps/web/lib/actions/partners/create-program-application.ts:127-461](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L127-L461)
### Fraud Checks and Application Events
When `createApplicationAndEnrollment` executes successfully for logged-in partners, asynchronous background tasks run via Vercel's `waitUntil` helper function to handle notifications, webhooks, fraud detection, and analytics tracking.
Sources: [apps/web/lib/actions/partners/create-program-application.ts:325-386](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L325-L386)
| Background Task | Trigger / Handler Function | Purpose |
| :--- | :--- | :--- |
| Program Notification | `notifyProgramApplication` | Sends application notifications to program administrators. |
| Auto-Approval | `autoApprovePartnerJob` | Automatically approves the partner enrollment if `autoApprovePartnersEnabledAt` is active on the group. |
| Webhook Dispatch | `sendWorkspaceWebhook` | Fires a `partner.application_submitted` workspace webhook payload validated against `partnerApplicationWebhookSchema`. |
| Fraud Detection | `detectAndRecordFraudApplication` | Evaluates submission context to detect and log fraudulent application behavior. |
| Event Tracking | `markApplicationEventSubmitted` | Marks the application lifecycle event as submitted for the given enrollment. |
| Search Index Sync | `queuePartnerSearchSync` | Queues search indexing updates for the newly created program enrollment ID. |
Sources: [apps/web/lib/actions/partners/create-program-application.ts:334-384](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L334-L384)
> [!WARNING]
> If a logged-in partner attempts an in-app application with an incomplete network profile checklist or a non-approved network status (`networkStatus` not equal to `"approved"` or `"trusted"`), the action immediately throws an error blocking submission.
Sources: [apps/web/lib/actions/partners/create-program-application.ts:188-212](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L188-L212)
> [!CAUTION]
> Unauthenticated applications set a 7-day persistent HTTP-only cookie named `programApplicationIds` tracking submitted application IDs. If the corresponding application event cookie is present, it directly updates the `programApplicationEvent` table row.
Sources: [apps/web/lib/actions/partners/create-program-application.ts:423-452](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/create-program-application.ts#L423-L452)
## Application Review and Approval Pipeline
### Overview
The application review and approval pipeline handles workspace dashboard management of candidate program submissions. Workspace administrators use the applications dashboard interface to inspect pending applications, filter records across statuses, and invoke approval mutations that finalize partner enrollments.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/applications-nav.tsx:8-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/applications-nav.tsx#L8-L42), [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/page.tsx:1-5](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/page.tsx#L1-L5)
### Dashboard Applications Layout and Navigation
The dashboard layout is structured around an applications shell with navigation tabs mapped to `ProgramApplicationStatus` enums. The navigation component preserves search parameters (`search`, `country`, `groupId`, `sortBy`, `sortOrder`) across view switches.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/layout.tsx:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/layout.tsx#L1-L26), [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/applications-nav.tsx:1-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/applications-nav.tsx#L1-L42)
| Tab ID / Status | Label | Icon Component | Associated Route |
| :--- | :--- | :--- | :--- |
| `ProgramApplicationStatus.pending` | Pending | `CircleHalfDottedClock` | `/${slug}/program/partners/applications` |
| `ProgramApplicationStatus.approved` | Approved | `CircleCheck` | Derived tab interface |
| `ProgramApplicationStatus.rejected` | Rejected | `UserXmark` | Derived tab interface |
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/applications/applications-nav.tsx:16-32](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/applications/applications-nav.tsx#L16-L32)
### Approval Execution Walkthrough
When an administrator approves a candidate partner submission, the request flows through either a server action or a REST API route. The execution sequence guarantees workspace permissions and plan validation before mutating state:
1. `withWorkspace` or `authActionClient` verifies the session context, workspace association, and role permissions via `throwIfNoPermission` requiring `owner` or `member` roles.
2. `getDefaultProgramIdOrThrow` resolves the target program identifier from the active workspace.
3. `approveProgramApplication` executes the core enrollment mutation for the given `partnerId`, `programId`, `groupId`, and approving `userId`.
Sources: [apps/web/lib/actions/partners/approve-program-application.ts:1-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/approve-program-application.ts#L1-L34), [apps/web/app/ee/api/program-applications/approve/route.ts:9-32](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/approve/route.ts#L9-L32)
> [!NOTE]
> The API endpoint at `/api/program-applications/approve` strictly enforces subscription plan entitlements, requiring the workspace to be on the `business`, `advanced`, or `enterprise` plans.
Sources: [apps/web/app/ee/api/program-applications/approve/route.ts:28-31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/approve/route.ts#L28-L31)
### Approval API and Action Interfaces
| Interface / Endpoint | Input Validation Schema | Required Roles | Target Handler Function |
| :--- | :--- | :--- | :--- |
| `approveProgramApplicationAction` (Server Action) | `inputSchema` (`partnerId`, `groupId`, `workspaceId`) | `owner`, `member` | `approveProgramApplication` |
| `POST /api/program-applications/approve` (REST API) | `approveProgramApplicationSchema` (`partnerId`, `groupId`) | `owner`, `member` | `approveProgramApplication` |
Sources: [apps/web/lib/actions/partners/approve-program-application.ts:10-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/approve-program-application.ts#L10-L33), [apps/web/app/ee/api/program-applications/approve/route.ts:9-31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/program-applications/approve/route.ts#L9-L31)
## Partner Lifecycle and Link Management
### Overview
The partner lifecycle and link management subsystem controls enrolled partners, directory queries, status mutations, partner switching, and tracking link generation with custom reward overrides. Workspace operators interact with enrolled partners via the partners directory view and individual partner management layouts.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/page.tsx:1-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/page.tsx#L1-L28), [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/partnerId/layout.tsx:1-71](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/%5BpartnerId%5D/layout.tsx#L1-L71)
### Enrolled Partner Directory and Queries
The REST API endpoint at `GET /api/partners` retrieves all enrolled partners for a program, supporting custom filters and sorting parameters.
```typescript
export const GET = withWorkspace(
async ({ workspace, searchParams }) => {
const programId = getDefaultProgramIdOrThrow(workspace);
const filterOverrides = parsePartnerFilterParams(searchParams);
const paramsToParse = {
...searchParams,
...(filterOverrides.partnerTagId && {
partnerTagId: filterOverrides.partnerTagId,
}),
...(filterOverrides.groupId !== undefined && {
groupId: filterOverrides.groupId,
}),
...(filterOverrides.country !== undefined && {
country: filterOverrides.country,
}),
};
const { sortBy: sortByWithOldFields, ...parsedParams } =
getPartnersRouteQuerySchema.parse(paramsToParse);
const sortBy =
{
clicks: "totalClicks",
leads: "totalLeads",
conversions: "totalConversions",
sales: "totalSaleAmount",
saleAmount: "totalSaleAmount",
totalSales: "totalSaleAmount",
}[sortByWithOldFields] || sortByWithOldFields;
const partners = await getPartners({
...parsedParams,
sortBy,
programId,
});
// ...
},
{
requiredPlan: ["business", "advanced", "enterprise"],
},
);
```
Sources: [apps/web/app/ee/api/partners/route.ts:36-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/route.ts#L36-L79)
Legacy sorting fields such as `clicks`, `leads`, `conversions`, `sales`, `saleAmount`, and `totalSales` are mapped directly to canonical column names like `totalClicks`, `totalLeads`, `totalConversions`, and `totalSaleAmount`.
Sources: [apps/web/app/ee/api/partners/route.ts:57-65](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/route.ts#L57-L65)
### Tracking Link Generation and Reward Overrides
Partners can have custom tracking links created through `POST /api/partners/links`. This endpoint validates partner enrollment, checks advanced plan capabilities for reward assignments, and persists link-level rewards.
```typescript
export const POST = withWorkspace(
async ({ workspace, req, session }) => {
const programId = getDefaultProgramIdOrThrow(workspace);
const {
partnerId,
tenantId,
url,
key,
linkProps,
clickRewardId,
leadRewardId,
saleRewardId,
discountId,
} = createPartnerLinkSchemaInternal.parse(await parseRequestBody(req));
const program = await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});
if (!program.domain || !program.url) {
throw new DubApiError({
code: "bad_request",
message:
"You need to set a domain and url for this program before creating a link.",
});
}
// ...
},
{
requiredPlan: ["business", "advanced", "enterprise"],
requiredRoles: ["owner", "member"],
},
);
```
Sources: [apps/web/app/ee/api/partners/links/route.ts:93-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L93-L122)
> [!WARNING]
> Assigning link-level rewards requires the workspace plan capability `canUseAdvancedRewardLogic`. Attempting to assign custom link rewards on unauthorized plans throws a `forbidden` error with the `PARTNER_LEVEL_REWARDS_PLAN_ERROR` constant.
Sources: [apps/web/app/ee/api/partners/links/route.ts:204-214](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L204-L214)
### Partner Layout and State Switching
The dashboard layout for individual partners (`[partnerId]/layout.tsx`) handles route validation, active partner switching, and modal triggers for administrative actions.
```tsx
export default function ProgramPartnerLayout({
children,
}: {
children: ReactNode;
}) {
const { slug: workspaceSlug } = useWorkspace();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const params = useParams() as { slug: string; partnerId: string };
const { partner, error: partnerError } = usePartner({
partnerId: params.partnerId,
});
if (partnerError && partnerError.status === 404) {
redirect(`/${workspaceSlug}/program/partners`);
}
const switchToPartner = (newPartnerId: string) => {
if (params.partnerId === newPartnerId) return;
const url = `${pathname.replace(`/partners/${params.partnerId}`, `/partners/${newPartnerId}`)}?${searchParams.toString()}`;
router.push(url);
};
// ...
}
```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/partnerId/layout.tsx:72-95](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/%5BpartnerId%5D/layout.tsx#L72-L95)
## Navigation and Marketplace Routing
### Sidebar Navigation Integration
The partner program navigation is integrated into workspace and partner portal layouts via sidebar navigation components. In workspace contexts, `NAV_GROUPS` defines whether the "Partner Program" or "Short Links" group appears first based on `defaultProduct`. The partner program navigation item points to `/${slug}/program` and uses the `ConnectedDots4` icon, rendering an active state whenever the pathname starts with the workspace slug and is outside links or settings paths.
Sources: [apps/web/ui/layout/sidebar/app-sidebar-nav.tsx:90-124](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx#L90-L124)
In portal contexts, `partners-sidebar-nav.tsx` establishes top-level navigation groups including Programs, Payouts, Profile settings, and Messages. The Programs group remains active across `/programs` and `/marketplace` paths, while the Messages badge dynamically displays unread message counts capped at 9 via `unreadMessagesCount`.
Sources: [apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx:67-104](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx#L67-L104)
### Program Switching and Dropdown State
Program switching within the partner portal is driven by the `PartnerProgramDropdown` component, which queries partner profiles and program enrollments via SWR hooks. It extracts the current `programSlug` from route parameters and matches it against approved enrollments to derive the `selectedProgram`.
```tsx
const selectedProgram = useMemo(() => {
const program = programEnrollments?.find(
(programEnrollment) => programEnrollment.program.slug === programSlug,
);
return programSlug && program
? {
...program.program,
logo:
program.program.logo || `${OG_AVATAR_URL}${program.program.name}`,
status: program.status,
}
: undefined;
}, [programSlug, programEnrollments]);
```
Sources: [apps/web/ui/layout/sidebar/partner-program-dropdown.tsx:31-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx#L31-L44)
The dropdown features a command search menu (`cmd-k`) allowing partners to filter through approved programs. Selecting an alternative program executes a router push using a dynamic `href` helper that preserves query parameters while replacing the existing program slug in the pathname.
```tsx
const href = useCallback(
(slug: string) =>
selectedProgram
? `${pathname.replace(selectedProgram.slug, slug)}${searchParamsString.length > 0 ? `?${searchParamsString}` : ""}`
: `/programs/${slug}`,
[pathname, selectedProgram],
);
```
Sources: [apps/web/ui/layout/sidebar/partner-program-dropdown.tsx:173-179](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx#L173-L179)
### Middleware Routing Across Portal Subdomains
Incoming requests to partner portal subdomains are intercepted by `PartnersMiddleware`, which evaluates authentication tokens, path structures, and default partner identifiers.
```typescript
const AUTHENTICATED_PATHS = [
"/programs",
"/marketplace",
"/onboarding",
"/settings",
"/profile",
"/messages",
"/payouts",
"/account",
"/invite",
"/rewind",
];
```
Sources: [apps/web/lib/middleware/partners.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L12-L23)
The middleware executes an explicit validation and routing sequence:
1. `parse(req)` extracts the path, full path, and search parameters.
2. `getUserViaToken(req)` resolves the active session user.
3. If the path matches an authenticated route and no user exists, it redirects unauthenticated requests to `/login?next=...` (or `/[programSlug]/login` for custom program paths).
4. If a user exists but lacks a `defaultPartnerId` (and is not accessing `/onboarding`, `/account`, or an invite link), the middleware redirects them to `/onboarding`.
5. Validated internal redirects via `searchParamsObj.next` are processed securely while omitting onboarding routes.
6. Root and partner network routes (`/` or `/pn_*`) are rewritten or redirected to `/programs`, while all other requests are rewritten to the underlying `/partners.dub.co` subdomain.
Sources: [apps/web/lib/middleware/partners.ts:25-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L25-L122)
## Related
- [[Commission Rules and Rewards]]
- [[Partner Portal and Onboarding]]
---
## Technical docs: Commission Rules and Rewards
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/commission-rules-and-rewards
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/partner-referrals/create-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts)
- [apps/web/app/ee/api/workflows/create-partner-commission/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts)
- [apps/web/lib/zod/schemas/rewards.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts)
- [apps/web/app/ee/api/commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts)
- [apps/web/lib/jobs/handlers/create-custom-commission-job.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/create-custom-commission-job.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/app/ee/api/cron/rewards/queue-custom-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/groups/groupSlug/rewards/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/groups/%5BgroupSlug%5D/rewards/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/commissions/page.tsx)
- [apps/web/lib/api/sales/construct-reward-amount.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/construct-reward-amount.ts)
- [apps/web/lib/partner-referrals/create-network-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts)
- [apps/web/lib/api/rewards/custom-reward-utils.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts)
- [apps/web/lib/rewardful/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts)
- [apps/web/lib/lemonsqueezy/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts)
- [apps/web/lib/api/sales/calculate-sale-earnings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/calculate-sale-earnings.ts)
- [apps/web/ui/partners/rewards/rewards-logic.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/rewards/rewards-logic.tsx)
- [apps/web/lib/partners/determine-partner-reward.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts)
- [apps/web/lib/partnerstack/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/program/reward/form.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx)
- [apps/web/lib/api/rewards/create-custom-reward-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts)
- [apps/web/lib/api/commissions/update-partner-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts)
- [apps/web/ui/partners/rewards/reward-event-descriptions.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/rewards/reward-event-descriptions.tsx)
- [apps/web/lib/firstpromoter/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
- [apps/web/lib/api/commissions/create-manual-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/earnings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx)
- [apps/web/scripts/customers/framer/tally-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/framer/tally-commissions.ts)
- [apps/web/ui/partners/custom-reward-description.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/custom-reward-description.tsx)
## Overview
The Commission Rules and Rewards engine manages partner compensation structures by defining flexible earning models, evaluation criteria, and automated payout pipelines across referral programs. It solves the complexity of multi-tiered affiliate incentives by supporting both percentage-based revenue shares and flat-fee payouts, combined with conditional logic modifiers based on partner performance metrics, geographic attributes, or specific product IDs. The system ensures precise financial tracking through automated asynchronous workflows that enforce spend limits, duration caps, and strict eligibility checks prior to recording commissions. Furthermore, it integrates cron-driven periodic execution for custom retainers alongside native reconciliation mechanisms for importing foreign platform commissions, providing comprehensive administrative control over partner earnings and lifecycle adjustments. Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:366-408](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L366-L408), [apps/web/lib/zod/schemas/rewards.ts:113-173](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L113-L173), [apps/web/lib/partners/determine-partner-reward.ts:118-150](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L118-L150), and [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:150-178](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L150-L178)
## Reward Configuration and Schema Rules
### Overview
The reward configuration and schema validation engine structures partner compensation parameters using Zod schemas and Prisma models. Payout structures divide into two primary models: flat incentives and percentage revenue shares, defined alongside commission types that determine whether payouts run as recurring ongoing rewards or single one-off transactions. Sources: [apps/web/lib/zod/schemas/rewards.ts:19-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L19-L32), [apps/web/ui/partners/rewards/rewards-logic.tsx:74-83](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/rewards/rewards-logic.tsx#L74-L83)
### Event Types and Commission Structures
The system supports five distinct event types for triggering compensation: sale, lead, click, referral, and custom. Each event type maintains specialized metadata and target use cases, ranging from high-DR traffic publishers to multi-month B2B sales cycles and recurring retainers. Sources: [apps/web/ui/partners/rewards/reward-event-descriptions.tsx:11-60](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/rewards/reward-event-descriptions.tsx#L11-L60)
| Event Type | Title | Description | Best For |
| :--- | :--- | :--- | :--- |
| `sale` | Sale reward | Reward when revenue is generated | Partners, creators, and long term partnerships |
| `lead` | Lead reward | Reward for sign ups or demos | B2B, demos, waitlists, or products with longer sales cycles |
| `click` | Click reward | Reward for traffic and reach | Publishers with high DR sites and trusted partners only |
| `referral` | Partner referral reward | Reward when partners refer more partners | Driving partner growth to your program |
| `custom` | Custom reward | Pay a fixed amount on a regular cadence | Retainers and scheduled partner payments |
Sources: [apps/web/ui/partners/rewards/reward-event-descriptions.tsx:21-59](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/rewards/reward-event-descriptions.tsx#L21-L59)
### Commission Types and Duration Parameters
When configuring sale rewards, administrators choose between two commission structures: recurring ongoing payouts or one-off single payouts. Selecting recurring rewards enables duration configuration options, defaulting to lifetime or bounded month intervals. Sources: [apps/web/lib/zod/schemas/rewards.ts:19-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L19-L32), [apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx:254-354](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx#L254-L354)
> [!NOTE]
> Values of `0` and `1` month intervals are automatically filtered out from selectable recurring max durations in the form UI, as 1-month periods are restricted exclusively to discount rules.
> Sources: [apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx:346-347](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/program/reward/form.tsx#L346-L347)
### Reward Condition Entities and Attributes
Reward conditions evaluate nested entity attributes across four distinct domain entities: `partner`, `customer`, `sale`, and `lead`. Each entity exposes typed attributes for constructing rule modifiers and criteria groups. Sources: [apps/web/lib/zod/schemas/rewards.ts:34-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L34-L111)
- **Partner Entity**: Evaluates `country`, `totalClicks`, `totalLeads`, `totalConversions`, `totalSaleAmount` (currency), and `totalCommissions` (currency). Sources: [apps/web/lib/zod/schemas/rewards.ts:64-98](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L64-L98)
- **Customer Entity**: Evaluates `country`, alongside event-specific source channels such as tracked APIs, submitted partner leads, Stripe free trials, and HubSpot integrations. Sources: [apps/web/lib/zod/schemas/rewards.ts:101-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L101-L111), [apps/web/lib/zod/schemas/rewards.ts:131-167](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L131-L167)
- **Sale Entity**: Evaluates `productId`, `amount` (currency), `type` (new versus recurring), subscription duration months, subscription start dates, and signup dates. Sources: [apps/web/lib/zod/schemas/rewards.ts:229-253](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L229-L253)
- **Lead Entity**: Evaluates metadata attributes. Sources: [apps/web/lib/zod/schemas/rewards.ts:52-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L52-L62)
## Commission Calculation and Reward Determination
### Overview
Evaluating commission rules and determining final reward amounts involves resolving context-specific modifiers, checking event column mappings, and applying rate structures against sales volumes. When an event occurs, the system maps the event type (`click`, `lead`, or `sale`) to its corresponding reward database column using `REWARD_EVENT_COLUMN_MAPPING`. Sources: [apps/web/lib/partners/determine-partner-reward.ts:14-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L14-L18), [apps/web/lib/partners/determine-partner-reward.ts:89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L89)
### Partner Reward Determination Call Chain
The execution path for resolving an applicable partner reward coordinates link-level overrides, program enrollment defaults, metric aggregation, condition evaluation, and final parsing.
```mermaid
graph TD
A[determinePartnerReward] --> B[getLinkRewards]
B --> C{linkRewards found?}
C -->|Yes| D[Select linkReward column]
C -->|No| E[Select programEnrollment column]
D --> F[aggregatePartnerLinksStats]
E --> F
F --> G{reward.modifiers exist?}
G -->|Yes| H[evaluateRewardConditions]
G -->|No| I[getRewardAmount]
H -->|Matched| J[Override reward parameters]
H -->|No Match| I
J --> I
I --> K{amount === 0?}
K -->|Yes| L[Return null]
K -->|No| M[RewardSchema.parse]
```
Sources: [apps/web/lib/partners/determine-partner-reward.ts:78-162](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L78-L162)
1. `determinePartnerReward()` — Entry point that accepts an `event`, `programEnrollment`, `linkId`, and optional `context`. Sources: [apps/web/lib/partners/determine-partner-reward.ts:78-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L78-L88)
2. `getLinkRewards()` — Queries `prisma.linkReward.findUnique()` using the `linkId` to check if a specific link overrides the program's default reward. Sources: [apps/web/lib/partners/determine-partner-reward.ts:91-94](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L91-L94), [apps/web/lib/partners/determine-partner-reward.ts:261-282](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L261-L282)
3. `aggregatePartnerLinksStats()` — Aggregates link metrics to construct the partner statistics context, including total commissions and country codes. Sources: [apps/web/lib/partners/determine-partner-reward.ts:104-114](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L104-L114)
4. `evaluateRewardConditions()` — Safely parses reward modifiers via Zod and evaluates them against the enriched context to discover matching rule tiers. Sources: [apps/web/lib/partners/determine-partner-reward.ts:118-128](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L118-L128)
5. `getRewardAmount()` — Computes the numeric reward value from the serialized reward definition. Sources: [apps/web/lib/partners/determine-partner-reward.ts:152](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L152)
6. `RewardSchema.parse()` — Validates and serializes the final partner reward object before returning it alongside any matched condition. Sources: [apps/web/lib/partners/determine-partner-reward.ts:158-161](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L158-L161)
> [!WARNING]
> When Stripe line items include a `productId` modifier, `determinePartnerRewards` splits the evaluation per product by iterating over `context.sale.products`, assigning `product.amount` while forcing quantity to `1` for each product iteration.
> Sources: [apps/web/lib/partners/determine-partner-reward.ts:166-237](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L166-L237)
### Earnings Calculation and Formatting
Once a reward is determined, actual earnings are calculated via `calculateSaleEarnings`, which distinguishes between flat-fee and percentage models. Flat-fee rewards multiply sale quantity by the reward amount, whereas percentage rewards calculate rounded integer cents from the sale amount. Sources: [apps/web/lib/api/sales/calculate-sale-earnings.ts:8-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/calculate-sale-earnings.ts#L8-L28)
```typescript
export const calculateSaleEarnings = ({
reward,
sale,
}: {
reward: Pick;
sale: Pick;
}) => {
if (!reward) {
return 0;
}
const amount = getRewardAmount(reward);
if (reward.type === "flat") {
return sale.quantity * amount;
} else if (reward.type === "percentage") {
return Math.round((sale.amount * amount) / 100);
}
return 0;
};
```
Sources: [apps/web/lib/api/sales/calculate-sale-earnings.ts:8-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/calculate-sale-earnings.ts#L8-L28)
For user-facing displays, `constructRewardAmount` evaluates modifier ranges. If every modifier matches the primary reward's type and maximum duration, it constructs a range string formatted as `"Up to X%"`, or formats flat values using `currencyFormatter`. If modifiers do not match the primary reward parameters, it falls back to displaying the primary reward value directly. Sources: [apps/web/lib/api/sales/construct-reward-amount.ts:6-79](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/construct-reward-amount.ts#L6-L79)
### Reward Event Mapping and Multipliers Reference
| Event Type | Mapping Constant Key | Target Database Column | Description |
| :--- | :--- | :--- | :--- |
| `click` | `EventType.click` | `clickReward` | Reward triggered upon link click events |
| `lead` | `EventType.lead` | `leadReward` | Reward triggered upon lead conversion |
| `sale` | `EventType.sale` | `saleReward` | Reward triggered upon revenue generation |
Sources: [apps/web/lib/partners/determine-partner-reward.ts:14-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/determine-partner-reward.ts#L14-L18)
> [!TIP]
> When constructing display ranges in `constructRewardAmount`, modifiers with undefined amounts fall back to `Infinity` for minimum bounds and `0` for maximum bounds during calculation.
> Sources: [apps/web/lib/api/sales/construct-reward-amount.ts:40-56](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/sales/construct-reward-amount.ts#L40-L56)
## Partner Commission Workflow Execution
### Overview
The partner commission workflow execution pipeline processes newly generated commissions through a series of asynchronous validation steps, duration checks, earnings clamping rules, and post-processing triggers. When a commission workflow is invoked, raw earnings are computed based on event types (such as multiplying lead quantities or reducing sale items), evaluated against max duration constraints for recurring or first-sale subscriptions, checked against spend limits, validated for fraud or custom reward enrollment status, and finally dispatched alongside webhook and cron sync tasks.
Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:366-470](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L366-L470), [apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:16-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L16-L33)
### Spend Limit Clamping and Fraud Gatechecks
Before commission creation is finalized, `clampEarningsToSpendLimit()` calculates whether a partner has exceeded configured spend limits for a specific reward interval. The reward cap scope differs by event type: sales are evaluated at both the partner and customer level, whereas clicks and leads are restricted at the partner level only.
Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:773-775](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L773-L775), [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:802-838](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L802-L838)
The clamping helper queries existing commission records matching the criteria, sums their earnings, and clamps the current earnings between zero and the remaining spend limit budget.
```typescript
async function clampEarningsToSpendLimit({
reward,
earnings,
programId,
partnerId,
customerId,
referenceDate,
}: {
reward: Pick<
RewardProps,
"event" | "spendLimitAmount" | "spendLimitInterval"
>;
earnings: number;
programId: string;
partnerId: string;
customerId: string;
referenceDate: Date;
}) {
if (
earnings === 0 ||
!reward.spendLimitAmount ||
!reward.spendLimitInterval
) {
return earnings;
}
const { startDate, endDate } = getRewardSpendLimitWindow({
spendLimitInterval: reward.spendLimitInterval,
referenceDate,
});
const {
_sum: { earnings: totalEarnings },
} = await prisma.commission.aggregate({
where: {
programId,
partnerId,
...(reward.event === "sale" ? { customerId } : {}),
type: reward.event,
status: {
in: ["pending", "processed", "paid", "hold"],
},
...(startDate && endDate
? {
createdAt: {
gte: startDate,
lte: endDate,
},
}
: {}),
},
_sum: {
earnings: true,
},
});
return Math.max(
0,
Math.min(earnings, reward.spendLimitAmount - (totalEarnings ?? 0)),
);
}
```
Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:776-838](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L776-L838)
> [!CAUTION]
> Custom reward jobs are queued from a snapshot of eligible enrollments. The system re-checks at write time (`programEnrollment.customRewardId === rewardId` and `status === "approved"`) to prevent paying out partners who were banned, deactivated, or moved off a reward before the workflow executed.
> Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:446-463](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L446-L463)
### Workflow Post-Processing Steps and Cron Aggregation
Once earnings are verified, the execution pipeline wraps up by mapping asynchronous notifications and synchronization actions into an array of execution results. Concurrently, due commissions are aggregated into payouts via Upstash QStash job batches.
Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:760-771](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L760-L771), [apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts:38-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L38-L58)
The table below outlines the post-processing steps mapped during the final workflow return phase.
| Step Name | Description | Sources |
| :--- | :--- | :--- |
| `sendWorkspaceWebhook` | Dispatches workspace-level webhook events regarding the new commission | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:761](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L761) |
| `sendPartnerPostback` | Triggers external partner postback URLs configured for conversion tracking | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:762](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L762) |
| `syncTotalCommissions` | Synchronizes partner and workspace aggregate commission metrics | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:763](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L763) |
| `notifyPartnerCommission` | Sends direct notifications alerting the partner of earned commissions | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:764](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L764) |
| `executeWorkflows` | Triggers internal workflow listeners for metrics and event hooks | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:765](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L765) |
| `triggerAggregateDueCommissions` | Enqueues background cron jobs to aggregate pending due commissions | [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:766](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L766) |
Sources: [apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts:760-771](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L760-L771)
## Recurring and Custom Scheduled Rewards
### Cron Cadence Evaluation and UTC Period Calculations
The custom reward subsystem relies on daily UTC cron executions to evaluate scheduled payouts. The cron entry point at `/api/cron/rewards/queue-custom-commissions` initializes by retrieving the current period date in UTC using `getUtcPeriodDate()`, which parses date boundaries via `toUtcDateOnly()` and formats them into a standardized `YYYY-MM-DD` string.
Sources: [apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:12-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts#L12-L13), [apps/web/lib/api/rewards/custom-reward-utils.ts:22-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L22-L42)
Cadence evaluation is governed by `isCadenceDue()`, which compares the target date against a reward's anchor date across four distinct frequency tiers. If the target date precedes the anchor date, evaluation immediately returns `false`.
Sources: [apps/web/lib/api/rewards/custom-reward-utils.ts:61-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L61-L70)
| Frequency | Evaluation Logic | Sources |
| :--- | :--- | :--- |
| `day` | Computes calendar day difference via `differenceInCalendarDays`; returns `true` if remainder with interval is zero. | [apps/web/lib/api/rewards/custom-reward-utils.ts:74-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L74-L77) |
| `week` | Computes calendar day difference, multiplying interval by 7; checks if remainder is zero. | [apps/web/lib/api/rewards/custom-reward-utils.ts:79-82](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L79-L82) |
| `month` | Validates that current UTC day matches expected day of month (accounting for shorter months via `expectedUtcDayOfMonth`), then verifies calendar month difference modulo interval equals zero. | [apps/web/lib/api/rewards/custom-reward-utils.ts:84-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L84-L93) |
| `year` | Validates matching month index and expected day of month, then checks calendar year difference modulo interval equals zero. | [apps/web/lib/api/rewards/custom-reward-utils.ts:95-104](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L95-L104) |
Sources: [apps/web/lib/api/rewards/custom-reward-utils.ts:71-105](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L71-L105)
> [!NOTE]
> When evaluating monthly and yearly cadences, `expectedUtcDayOfMonth` safely clamps anchor dates (such as the 31st) to the final day of shorter months, preventing skipped or mismatched intervals during edge-case periods.
> Sources: [apps/web/lib/api/rewards/custom-reward-utils.ts:48-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L48-L60)
### Custom Reward Dispatch Pipelines
Once due rewards are identified via `findDueCustomRewards()`, the cron job fans out execution using `createCustomCommissionJob.dispatchBatch()`. Each batch entry assigns a deterministic deduplication identifier (`custom-commission-${rewardId}-${periodDate}`) and enforces flow control with a parallelism limit of 5.
Sources: [apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:14-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts#L14-L35)
The execution call chain processes custom commissions through a pagination and recursion pipeline:
`GET /api/cron/rewards/queue-custom-commissions` → `createCustomCommissionJob.handle()` → `createCustomRewardCommissions()` → `dispatchWorkflows()`
Sources: [apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts:12-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts#L12-L35), [apps/web/lib/jobs/handlers/create-custom-commission-job.ts:17-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/create-custom-commission-job.ts#L17-L33), [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:19-190](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L19-L190)
During `createCustomRewardCommissions()`, partner enrollments are fetched in pages of 100. For rewards configured with a `maxDuration`, the handler batch-fetches the earliest custom commission per partner using Prisma's `groupBy` and `_min` aggregation, evaluating `hasRewardMaxDurationElapsed()` to filter out partners who have exceeded their lifetime earning window.
Sources: [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:17-148](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L17-L148), [apps/web/lib/api/rewards/custom-reward-utils.ts:171-189](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L171-L189)
Eligible enrollments generate workflow jobs configured with an idempotency key built via `buildCommissionIdempotencyKey()`. If a result set reaches `PAGE_SIZE`, `nextCursor` returns the final partner identifier, prompting `createCustomCommissionJob` to re-dispatch itself with a 1-second delay to process the subsequent page.
Sources: [apps/web/lib/jobs/handlers/create-custom-commission-job.ts:18-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/create-custom-commission-job.ts#L18-32), [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:150-189](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L150-L189)
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| Batch enrollment pagination (`PAGE_SIZE = 100`) | Prevents memory exhaustion and timeout exceptions when processing large partner programs. | Introduces multi-page job chaining and recursive queue roundtrips. | [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L17), [apps/web/lib/jobs/handlers/create-custom-commission-job.ts:20-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/create-custom-commission-job.ts#L20-L31) |
| Grouped `_min` commission queries | Eliminates N+1 database roundtrips when checking partner duration elapsed status. | Requires additional memory lookup mapping across partner identifier subsets. | [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:102-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L102-L124) |
| SHA-256 idempotency hashing | Guarantees unique invoice identifiers per reward, partner, and period date combination. | Adds minor cryptographic hashing overhead during job payload construction. | [apps/web/lib/api/rewards/custom-reward-utils.ts:157-169](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L157-L169) |
Sources: [apps/web/lib/api/rewards/create-custom-reward-commissions.ts:17-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/create-custom-reward-commissions.ts#L17-L124), [apps/web/lib/api/rewards/custom-reward-utils.ts:157-169](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/rewards/custom-reward-utils.ts#L157-L169), [apps/web/lib/jobs/handlers/create-custom-commission-job.ts:20-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/create-custom-commission-job.ts#L20-L31)
## Manual Adjustments and Direct Creation
### Overview
The manual commission subsystem provides programmatic endpoints to retrieve, generate, and update partner commissions outside of standard automated tracking events. Workspace administrators can list commissions via the `GET /api/commissions` endpoint or submit manual commission creations and adjustments via `POST /api/commissions`. When a request hits the `POST` route, it validates the request body using `createManualCommissionBodySchema` and invokes `createManualCommissions()`. Depending on whether the manual entry type is configured as a custom adjustment or a specific conversion event, the creation logic branches into direct queueing or event resolution.
Sources: [apps/web/app/(ee)/api/commissions/route.ts:18-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts#L18-L80), [apps/web/lib/api/commissions/create-manual-commissions.ts:83-113](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts#L83-L113)
### Manual Creation Execution Walkthrough
When creating manual commissions via `createManualCommissions()`, the execution path follows a sequence of validations, event insertions, and background tasks:
1. `getProgramEnrollmentOrThrow()` verifies that the partner is enrolled in the target program and retrieves their associated links.
2. If `type === "custom"`, `queuePartnerCommissionCreation()` directly queues a custom commission with `CommissionSource.user` and returns immediately.
3. For lead or sale types, `resolveLinkAndCustomer()` resolves the appropriate target link and customer record.
4. If `type === "sale"` and `importStripeInvoices` is requested, the system verifies workspace Stripe connection settings and customer Stripe IDs, throwing a `DubApiError` if validation fails. It also checks for invoice collisions using `prisma.commission.findUnique()`.
5. `recordEvents()` logs the underlying click, lead, or sale events.
6. Commissions are pushed to an array (`commissionsToCreate`), then sequentially queued via `queuePartnerCommissionCreation()`, where only the final iteration triggers aggregate due commissions (`triggerAggregateDueCommissions: index === commissionsToCreate.length - 1`).
7. Finally, `waitUntil(executeSideEffects(...))` dispatches any secondary side effects in the background.
Sources: [apps/web/lib/api/commissions/create-manual-commissions.ts:86-258](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts#L86-L258)
### Commission Update and Reconcile Workflow
Partner commissions can be modified via `updatePartnerCommission()`, which handles altering sale amounts, converting currencies using `convertCurrency()`, recalculating earnings via `calculateSaleEarnings()`, and transitioning commission statuses.
```typescript
// Call-chain for updating a partner commission:
// updatePartnerCommission() → prisma.commission.findUnique() → convertCurrency() (if non-USD)
// → determinePartnerReward() → calculateSaleEarnings() → prisma.commission.update()
// → reconcilePayoutAmounts() → waitUntil(syncTotalCommissions() + trackCommissionActivityLog())
```
Sources: [apps/web/lib/api/commissions/update-partner-commission.ts:31-327](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L31-L327)
> [!CAUTION]
> Commissions that have already been paid (`commission.status === "paid"`) or belong to locked payouts (`!MUTABLE_PAYOUT_STATUSES.includes(commission.payout.status)`) cannot be updated. Attempting to modify them throws a `DubApiError` with a `bad_request` or `not_found` code.
Sources: [apps/web/lib/api/commissions/update-partner-commission.ts:66-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L66-L88)
> [!IMPORTANT]
> When updating a commission's status to `fraud` or `canceled` with `updateHistoricalCommissions` enabled, the system automatically sweeps un-paid historical commissions for the same customer and partner combination, updates their status, nullifies their `payoutId`, and triggers payout reconciliation across all affected payout identifiers.
Sources: [apps/web/lib/api/commissions/update-partner-commission.ts:232-300](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L232-300)
### API Endpoints and Parameters Reference
The manual commission and adjustments routes support specific query parameters and body schemas for interacting with partner financial records.
| Endpoint / Function | Parameter / Field | Type | Description | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `GET /api/commissions` | `partnerId` | string (optional) | Filters returned commissions by specific partner identifier. | [apps/web/app/(ee)/api/commissions/route.ts:22-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts#L22-L27) |
| `GET /api/commissions` | `tenantId` | string (optional) | Resolves partner ID via program enrollment lookup when partner ID is absent. | [apps/web/app/(ee)/api/commissions/route.ts:22-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts#L22-L50) |
| `POST /api/commissions` | `type` | string | Commission type (e.g., custom, lead, sale). Custom negative amounts trigger clawbacks. | [apps/web/app/(ee)/api/commissions/route.ts:78-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts#L78-L100) |
| `updatePartnerCommission()` | `modifySaleAmount` | number (optional) | Increments or decrements the existing sale amount before FX conversion and recalculation. | [apps/web/lib/api/commissions/update-partner-commission.ts:41-138](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L41-L138) |
| `updatePartnerCommission()` | `updateHistoricalCommissions` | boolean | Flag to propagate fraud or cancellation status updates across historical customer commissions. | [apps/web/lib/api/commissions/update-partner-commission.ts:44-285](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L44-L285) |
Sources: [apps/web/app/(ee)/api/commissions/route.ts:22-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/commissions/route.ts#L22-L100), [apps/web/lib/api/commissions/update-partner-commission.ts:41-285](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/update-partner-commission.ts#L41-L285)
## External Importer Reconciliations and Tracking
### Overview
External platform commission ingestion pipelines allow historical or live commissions from foreign affiliate networks (such as Rewardful, FirstPromoter, PartnerStack, and Lemon Squeezy) to be synchronized and reconciled into Dub's core commission database. These ingestion modules parse foreign webhook payloads or API export responses, validate customer attribution via Tinybird click and lead events, handle foreign currency conversions using cached FX rates, and prevent double-crediting through dedoorprinting strategies like invoice ID lookups and ±1-hour sliding-window checks.
Sources: [apps/web/lib/rewardful/import-commissions.ts:1-404](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L1-L404), [apps/web/lib/lemonsqueezy/import-commissions.ts:263-610](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L263-L610), [apps/web/lib/partnerstack/import-commissions.ts:136-397](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L136-L397), [apps/web/lib/firstpromoter/import-commissions.ts:35-422](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L35-L422)
### Foreign Importer Ingestion Call-Chain
When ingesting commissions from external platforms, records pass through validation, conversion, attribution verification, and persistence steps.
```typescript
// Call-chain for importing external commissions:
// importCommissions() → FirstPromoterApi.listCommissions() / equivalent → prisma.customer.findMany()
// → getLeadEvents() → createCommission() → prisma.commission.findUnique() (deduplication check)
// → convertCurrencyWithFxRates() → prisma.commission.create() → recordSaleWithTimestamp()
// → prisma.link.update() → syncPartnerLinksStats() → syncTotalCommissions()
```
Sources: [apps/web/lib/rewardful/import-commissions.ts:141-403](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L141-L403), [apps/web/lib/lemonsqueezy/import-commissions.ts:377-609](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L377-L609), [apps/web/lib/firstpromoter/import-commissions.ts:35-421](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L35-L421)
> [!WARNING]
> If an external platform commission does not provide a direct Stripe invoice ID, importers fall back to deduplicating against existing Dub records using the customer's Stripe customer ID or external key combined with a $\pm 1$-hour creation timestamp window (`createdAt` between $t - 3600000$ and $t + 3600000$). This protects against duplicate commission records during transition periods where both systems record charges simultaneously.
Sources: [apps/web/lib/rewardful/import-commissions.ts:237-251](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L237-L251), [apps/web/lib/partnerstack/import-commissions.ts:294-306](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L294-L306), [apps/web/lib/firstpromoter/import-commissions.ts:251-263](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L251-L263)
### Currency Conversion and Resolution
External transactions originating in non-USD currencies are normalized using exchange rates retrieved from Redis caches (`fxRates:usd`). Lemon Squeezy integration logic explicitly checks alternative pricing structures if order subtotals are zero during initial subscription checkouts.
```typescript
function resolveAmountUsd({
amount,
amountUsd,
currency,
fxRates,
}: {
amount: number;
amountUsd: number | null | undefined;
currency: string;
fxRates: Record | null;
}): number | null {
if (amountUsd != null) {
return amountUsd;
}
if (currency.toUpperCase() === "USD") {
return amount;
}
if (!fxRates) {
return null;
}
return convertCurrencyWithFxRates({ currency, amount, fxRates }).amount;
}
```
Sources: [apps/web/lib/lemonsqueezy/import-commissions.ts:611-640](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L611-L640)
> [!TIP]
> Lemon Squeezy imports prioritize platform-provided USD totals (`amountUsd`). If the order subtotal evaluates to zero on a subscription's first charge, the importer inspects `firstOrderItemPrice` before attempting FX conversion.
Sources: [apps/web/lib/lemonsqueezy/import-commissions.ts:467-488](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L467-L488)
### Referral Earnings Views and Client Presentation
Partner dashboard interfaces and embed views render referral earnings by fetching paginated commission records via SWR hooks and presenting status badges, customer attribution identifiers, and formatted currency values.
| Component / Function | Data Source | Pagination Limit | Render Behavior | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `ReferralsEmbedEarnings` | `/api/embed/referrals/earnings` | `REFERRALS_EMBED_EARNINGS_LIMIT` | Renders a table of customer emails, creation timestamps, raw amounts, earnings, and status badges. | [apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx:23-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx#L23-L120) |
| `CommissionsPageClient` | `/api/admin/commissions` | N/A (Admin SWR) | Aggregates program-level commission timeseries data, filters by program ID, and renders analytics areas. | [apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx:42-166](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx#L42-L166) |
Sources: [apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx:23-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx#L23-L120), [apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx:42-166](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx#L42-L166)
## Related
- [[Conversion and Event Tracking]]
- [[Payout Processing]]
---
## Technical docs: PATCH Update partner country
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/updateadminpartnercountry
## Parameters
## Request Body
Updated country information
## Responses
## Try It
---
## Technical docs: GET Get shared platforms
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadminpartnersharedplatforms
## Parameters
## Responses
## Try It
---
## Technical docs: Payout Processing
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/payout-processing
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/partners/create-stablecoin-payout.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stablecoin-payout.ts)
- [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts)
- [apps/web/app/ee/api/cron/payouts/balance-available/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts)
- [apps/web/app/ee/api/cron/payouts/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/process/route.ts)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts)
- [apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts)
- [apps/web/app/ee/api/cron/payouts/payout-paid/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts)
- [apps/web/lib/tremendous/send-tremendous-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/payouts/payoutId/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/payouts/%5BpayoutId%5D/page.tsx)
- [apps/web/lib/actions/partners/confirm-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts)
- [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trigger-withdrawal/route.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/process/route.ts)
- [apps/web/app/ee/partners.dub.co/invoices/payoutId/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/invoices/%5BpayoutId%5D/route.tsx)
- [apps/web/lib/payouts/recompute-partner-payout-state.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/payouts/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/admin.dub.co/(dashboard)/payouts/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/payouts/payout-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/payouts/payout-table.tsx)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts)
- [apps/web/lib/constants/payouts-supported-countries.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/constants/payouts-supported-countries.ts)
- [apps/web/ui/partners/confirm-payouts-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/confirm-payouts-sheet.tsx)
- [apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts)
- [packages/email/src/templates/partner-payout-processed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-payout-processed.tsx)
- [apps/web/scripts/customers/framer/split-bounty-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/framer/split-bounty-payouts.ts)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts)
- [apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts)
- [apps/web/ui/partners/payout-status-descriptions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/payout-status-descriptions.ts)
- [apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts)
- [packages/email/src/templates/partner-payout-confirmed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-payout-confirmed.tsx)
- [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts)
## Overview
Payout processing orchestrates the end-to-end lifecycle of partner commissions, converting accumulated earnings into distributed funds through integrated financial providers like Stripe, PayPal, and Tremendous. The system handles scheduled commission aggregation, dynamic fee calculations, workspace payout confirmations, multi-currency conversions, and asynchronous balance settlements while maintaining idempotency and robust audit trails. By automating these workflows through cron jobs and webhooks, the platform ensures accurate disbursement tracking, automated partner notifications, and seamless compliance across internal and external payout channels.
Sources: [apps/web/lib/partners/create-stablecoin-payout.ts:38-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stablecoin-payout.ts#L38-L46), [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:59-92](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L59-L92), [apps/web/app/ee/api/cron/payouts/balance-available/route.ts:26-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/balance-available/route.ts#L26-L39), [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:23-63](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts#L23-L63), [apps/web/lib/actions/partners/confirm-payouts.ts:52-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L52-L78), [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:219-264](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts#L219-L264)
## Commission Aggregation and Eligibility
### Commission Aggregation and Eligibility
Partner commissions are aggregated into structured payouts through scheduled cron executions via `aggregateDueCommissionsForPartner`. This routine processes pending commissions by sorting them chronologically to determine period boundaries (`periodStart` and `periodEnd`), and either reuses an existing pending payout or instantiates a new record prefixed with `po_`.
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:219-264](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts#L219-L264)
### Aggregation Call-Chain Execution Walkthrough
The aggregation process follows a strict sequence to prevent race conditions across concurrent workers:
1. `aggregateDueCommissionsForPartner()` sorts input commissions by `createdAt` and instantiates or fetches a target payout record.
2. Raw SQL execution via `prisma.$executeRaw` updates candidate commissions to `processed` status and links them to the payout while verifying that target payout statuses remain mutable.
3. `prisma.commission.aggregate()` calculates the total earnings sum for the active payout to prevent stale precomputed balances.
4. Raw SQL updates the Payout table with the aggregated amount and updates `periodEnd` if a new payout was created.
5. `prisma.commission.findMany()` verifies successfully claimed commissions, which are subsequently passed to `trackCommissionStatusUpdate()` for activity logging.
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:234-362](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts#L234-L362)
> [!WARNING]
> Prisma's `updateMany` can drop `WHERE` clauses on MySQL under concurrent workloads, allowing multiple workers to claim the same commissions. The system mitigates this by executing raw SQL statements (`$executeRaw`) with strict inner joins against mutable payout statuses.
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts:266-284](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/process/route.ts#L266-L284)
### Eligibility Filtering and Cutoff Boundaries
Once commissions are aggregated, payout execution filters eligible records using workspace parameters, selection criteria, and cutoff periods. The `processPayouts` function executes a conditional `updateMany` query on payouts using `payoutIdSelectionWhere` and `getPayoutEligibilityFilter`, optionally constraining the upper bound of the period via `cutoffPeriodValue`.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:59-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L59-L82)
If a program operates under `hybrid` mode, secondary updates mark payouts linked to partners with `payoutsEnabledAt = null` as `external` to route them outside automated internal rails.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:107-119](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L107-L119)
## Fee Calculation and FX Quoting
### Overview
Payout fee calculation, multi-currency foreign exchange quoting, and recipient validation are handled during the payout processing pipeline. The system evaluates payment method types, applies fee waivers, generates Stripe FX quotes for non-USD transactions, and recomputes partner eligibility and default payout methods based on active account capabilities.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:138-208](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L138-L208), [apps/web/lib/payouts/recompute-partner-payout-state.ts:19-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L19-L136)
### Fee Calculation and Multi-Currency FX Quoting
The system determines the payout fee using the selected Stripe payment method and workspace fee configurations via `calculatePayoutFeeForMethod`, and then applies any available fee waivers via `calculatePayoutFeeWithWaiver`. Non-USD payment methods are mapped using the `nonUsdPaymentMethodTypes` dictionary, which handles specific currency conversions.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:25-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L25-L28), [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:138-164](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L138-L164)
```typescript
const nonUsdPaymentMethodTypes = {
sepa_debit: "eur",
acss_debit: "cad",
} as const;
```
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:25-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L25-L28)
When processing payment methods associated with non-USD currencies (such as `sepa_debit` for EUR or `acss_debit` for CAD), the system requests an FX quote via `createFxQuote` to retrieve the active exchange rate from Stripe before charging the invoice total.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:192-208](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L192-L208)
> [!WARNING]
> If Stripe's FX exchange rate returns null, zero, or a negative value, the payout process throws an execution error to prevent quoting or charging incorrect currency amounts.
Sources: [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts:203-208](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/process/process-payouts.ts#L203-L208)
### Partner Payout State Recomputation
Partner recipient validation and payout method eligibility are managed by `recomputePartnerPayoutState`. This function evaluates connected accounts and stablecoin accounts in parallel, checking account statuses, capabilities, and active configurations.
Sources: [apps/web/lib/payouts/recompute-partner-payout-state.ts:19-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L19-L48)
Payout methods are evaluated against a strict priority order defined in `PAYOUT_METHOD_PRIORITY`. The system filters active methods and preserves the partner's existing default payout method if it remains valid, otherwise falling back to the highest priority available method.
Sources: [apps/web/lib/payouts/recompute-partner-payout-state.ts:7-12](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L7-L12), [apps/web/lib/payouts/recompute-partner-payout-state.ts:68-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L68-L89)
| Payout Method Constant | Enum Reference | Activation Criteria |
| :--- | :--- | :--- |
| Stablecoin | `PartnerPayoutMethod.stablecoin` | Crypto wallet capabilities active (`stablecoinAccount`), wallet address present, and network defined |
| Stripe Connect | `PartnerPayoutMethod.connect` | Connect account payouts enabled (`payouts_enabled === true`) with active transfer capabilities |
| PayPal | `PartnerPayoutMethod.paypal` | Partner email record populated (`partner.paypalEmail`) |
| Tremendous | `PartnerPayoutMethod.tremendous` | Partner email record populated (`partner.tremendousEmail`) |
Sources: [apps/web/lib/payouts/recompute-partner-payout-state.ts:7-12](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L7-L12), [apps/web/lib/payouts/recompute-partner-payout-state.ts:55-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/recompute-partner-payout-state.ts#L55-L77)
Supported payout countries and their corresponding available methods are compiled across stablecoin, Connect, and PayPal networks using `PAYOUT_SUPPORTED_COUNTRIES`.
Sources: [apps/web/lib/constants/payouts-supported-countries.ts:10-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/constants/payouts-supported-countries.ts#L10-L22)
## Program Payout Confirmation Workflow
### Program Payout Confirmation Workflow
Workspace users initiate payout batch confirmation through the `confirmPayoutsAction` server action. This workflow validates user permissions, enforces usage limits and minimum invoice amounts, checks payment method mandates for direct debit accounts, and creates the corresponding invoice record in the database.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:52-218](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L52-L218)
### Confirmation Call-Chain Execution
The payout confirmation workflow executes a specific sequence of validations and state checks before persisting the invoice. The invocation path follows:
`confirmPayoutsAction()` → `getDefaultProgramIdOrThrow()` → `getProgramOrThrow()` → `throwIfNoPermission()` → `prisma.payout.aggregate()` → `getEligiblePayouts()` → `stripe.paymentMethods.retrieve()` → `checkPaymentMethodMandate()` → `prisma.$transaction()` → `createTremendousCampaignJob.dispatch()`
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:54-222](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L54-L222)
### Input Validation and Eligibility Rules
The incoming request is parsed and validated using a Zod schema defined in `confirmPayoutsSchema`. It validates workspace parameters, selected or excluded payout identifiers, fast settlement flags, and financial totals.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:30-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L30-L50)
> [!WARNING]
> Requests cannot combine `selectedPayoutIds` with `excludedPayoutIds` within the same operation. The schema validation super-refine rule adds a custom issue if both parameters contain values.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:42-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L42-L50)
Prior to invoice creation, the server action performs several strict guards:
- Requires the workspace to have a valid `stripeId`.
- Rejects fast settlement requests if `workspace.fastDirectDebitPayouts` is disabled.
- Enforces workspace payout usage limits (`workspace.payoutsUsage + amount > workspace.payoutsLimit`).
- Rejects payout totals falling below `INVOICE_MIN_PAYOUT_AMOUNT_CENTS` ($10).
- Restricts cutoff periods if eligible payouts exceed `CUTOFF_PERIOD_MAX_PAYOUTS`.
- Requires an active webhook subscribed to the `payout.confirmed` event if the invoice includes external payouts in non-internal payout modes.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:79-153](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L79-L153)
### Stripe Payment Method and Mandate Validation
The system retrieves the Stripe payment method using `stripe.paymentMethods.retrieve(paymentMethodId)` and validates that its customer ID matches `workspace.stripeId`.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:156-160](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L156-L160)
| Validation Check | Condition | Error / Action Taken |
| :--- | :--- | :--- |
| Payment Type Support | `!PAYMENT_METHOD_TYPES.includes(paymentMethod.type)` | Throws error restricting supported types |
| Fast Settlement ACH | `fastSettlement && paymentMethod.type !== "us_bank_account"` | Throws error restricting fast settlement to ACH |
| Direct Debit Mandate | `DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes(paymentMethod.type)` | Calls `checkPaymentMethodMandate()`; detaches payment method and throws if invalid |
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:162-187](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L162-L187)
> [!CAUTION]
> If a direct debit payment method lacks an active mandate during verification, the system automatically detaches the payment method via `stripe.paymentMethods.detach(paymentMethodId)` before throwing an error.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:175-187](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L175-L187)
### Invoice Generation and PDF Rendering
Once all validations pass, a Prisma transaction (`tx.invoice.create`) generates an invoice record. It calculates the next sequential invoice number by counting existing workspace invoices, padding the number to four digits, and combining it with `workspace.invoicePrefix`.
Sources: [apps/web/lib/actions/partners/confirm-payouts.ts:189-218](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/confirm-payouts.ts#L189-L218)
Partners can download or view generated PDF invoices via the route handler at `GET /partners.dub.co/invoices/[payoutId]`. This route fetches the payout and program details using `prisma.payout.findUniqueOrThrow`, verifies partner authorization, and checks that the payout status is included in `INVOICE_AVAILABLE_PAYOUT_STATUSES` and that its mode is not external.
Sources: [apps/web/app/ee/partners.dub.co/invoices/payoutId/route.tsx:36-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/invoices/%5BpayoutId%5D/route.tsx#L36-L74)
The PDF layout is compiled using `@react-pdf/renderer` and `react-pdf-tailwind`, including Dub's corporate address, US EIN tax ID, invoice number matching the payout identifier, payee details, and conditional tax notices such as EU or Australian GST/VAT reverse charge disclosures.
Sources: [apps/web/app/ee/partners.dub.co/invoices/payoutId/route.tsx:154-236](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/invoices/%5BpayoutId%5D/route.tsx#L154-L236)
## Stripe Connect and Stablecoin Disbursement
### Overview
Disbursement processing handles the final transfer of funds to partners via Stripe Connect or stablecoins, as well as automated financial account funding and liquidity management cron routines.
Sources: [apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts:1-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts#L1-L73), [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:1-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L1-L155), [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:1-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L1-L67)
### Queueing and Execution Call Chain
When an invoice charge succeeds, payouts are queued and processed asynchronously through specific route handlers and helper libraries. The execution flow follows this exact call chain:
`queueStripePayouts()` → QStash queue (`send-stripe-payout`) → `POST /api/cron/payouts/send-stripe-payout` → `createStripeTransfer()` or `createStablecoinPayout()`
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:16-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L16-L155), [apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts:18-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/send-stripe-payout/route.ts#L18-L73), [apps/web/lib/partners/create-stablecoin-payout.ts:38-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stablecoin-payout.ts#L38-L42), [apps/web/lib/partners/create-stripe-transfer.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts#L26-L36)
> [!IMPORTANT]
> The `chargeId` is passed as a `source_transaction` for card payouts to account for settlement latency, but is omitted for ACH and SEPA transfers since those payment methods settle asynchronously via `charge.succeeded` webhooks after approximately four days.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:80-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L80-L96), [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:142-146](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L142-L146)
### Stripe Financial Account Funding and Withdrawals
Stablecoin payouts require pre-funding Dub's Stripe financial account. When `fundsAvailable` is true, `queueStripePayouts` aggregates stablecoin payouts meeting or exceeding `MIN_WITHDRAWAL_AMOUNT_CENTS`, adds `STABLECOIN_PAYOUT_FIXED_FEE_CENTS` per payout, and calls `fundFinancialAccount()`.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts:35-78](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts#L35-L78)
Separately, the withdrawal cron job (`GET /api/cron/trigger-withdrawal`) runs twice daily at 1 AM and 1 PM UTC to withdraw excess funds from Stripe back to the bank account while keeping a reserved operational balance.
Sources: [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:9-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L9-L12), [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:41-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L41-L43)
| Balance Component | Calculation Source / Logic | Purpose |
| :--- | :--- | :--- |
| `currentAvailableBalance` | `stripeBalanceData.available.find(b => b.currency === "usd")?.amount ?? 0` | Funds immediately available for payout or withdrawal |
| `currentPendingBalance` | `stripeBalanceData.pending.find(b => b.currency === "usd")?.amount ?? 0` | Funds waiting to settle in Stripe |
| `currentNetBalance` | `currentPendingBalance < 0 ? currentAvailableBalance + currentPendingBalance : currentAvailableBalance` | Accounts for negative pending adjustments |
| `reservedBalance` | Hardcoded constant `30_000_00` ($30,000) | Ensures minimum operating liquidity remains in the account |
| `balanceToWithdraw` | `currentNetBalance - payoutsToBeSent - reservedBalance` | Net amount submitted to `stripe.payouts.create()` |
Sources: [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:28-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L28-L43), [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:59-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L59-L62)
> [!NOTE]
> If `balanceToWithdraw` calculates to less than or equal to zero, the withdrawal cron job safely exits without invoking `stripe.payouts.create()`.
Sources: [apps/web/app/ee/api/cron/trigger-withdrawal/route.ts:53-57](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/trigger-withdrawal/route.ts#L53-L57)
## PayPal, Tremendous, and External Channels
### Overview
Disbursements routed outside of Stripe operate through dedicated processor integrations and asynchronous channel handlers. PayPal batch payouts, Tremendous reward campaigns, and external webhook deliveries manage specialized partner payout flows when internal invoicing or external settlement modes are selected.
Sources: [apps/web/lib/tremendous/send-tremendous-payouts.ts:23-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts#L23-L31), [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts:9-14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts#L9-L14), [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:10-14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L10-L14)
### PayPal Batch Payout Execution
The `sendPaypalPayouts` function validates that the invoice payout mode is not set to external, queries for internal processing payouts utilizing the PayPal payment method where partners have active PayPal email addresses and enabled payouts, and submits them to the PayPal batch payout API.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:10-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L10-L51)
> [!NOTE]
> If no eligible PayPal payouts exist for the invoice, execution exits immediately without calling `createPayPalBatchPayout`.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:53-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L53-L56)
Once the batch payout is created, matching payout records transition to a `"sent"` status with a recorded `paidAt` timestamp, triggering background notification emails and referral commission queue jobs via Vercel's `waitUntil`.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:58-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L58-L104)
### Tremendous Campaign Fulfillment
The `sendTremendousPayouts` function handles gift card and digital reward distribution via the Tremendous API. It fetches partner data, verifies active payout settings and Tremendous email credentials, and aggregates previously processed payouts alongside current invoice payouts.
Sources: [apps/web/lib/tremendous/send-tremendous-payouts.ts:23-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts#L23-L111)
> [!WARNING]
> Total transferable amounts must fall strictly between `TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS` and `TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS`; violations throw explicit validation errors.
Sources: [apps/web/lib/tremendous/send-tremendous-payouts.ts:128-138](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts#L128-L138)
Orders are dispatched using an idempotency key generated from partner and payout identifiers. If the order status is successfully marked as `"EXECUTED"` with a valid delivery link, underlying payouts are updated to `"completed"`, and associated commissions are processed in batches of 250.
Sources: [apps/web/lib/tremendous/send-tremendous-payouts.ts:142-245](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts#L142-L245)
### External Payouts and Webhook Routing
When invoices specify external payout processing (`payoutMode === "external"`), `queueExternalPayouts` bypasses internal money movement, searches for workspace webhooks configured with the `payout.confirmed` trigger, validates payload schemas, and dispatches webhook notifications alongside batch confirmation emails.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts:9-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts#L9-L120)
| Handler Function | Target Channel | Key Filtering Criteria | Success State Transition |
| :--- | :--- | :--- | :--- |
| `sendPaypalPayouts` | PayPal API | `mode: "internal"`, `method: "paypal"`, `paypalEmail` present | Status updated to `"sent"` |
| `sendTremendousPayouts` | Tremendous API | `mode: "internal"`, `method: "tremendous"`, `tremendousEmail` present | Status updated to `"completed"` |
| `queueExternalPayouts` | Webhook / Email | `mode: "external"`, workspace `payout.confirmed` trigger | Dispatched via `sendWorkspaceWebhook` |
Sources: [apps/web/lib/tremendous/send-tremendous-payouts.ts:71-82](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tremendous/send-tremendous-payouts.ts#L71-L82), [apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts:50-85](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/queue-external-payouts.ts#L50-L85), [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:22-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L22-L36), [apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts:66-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts#L66-L74)
## Settlement Lifecycle and Webhook Handlers
### Overview
The settlement lifecycle and webhook handlers manage asynchronous balance checking, Stripe Connect webhook ingestion, and partner email notifications. When charges succeed, settlement timing is validated against underlying balance transactions before payouts are queued or scheduled via QStash.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:68-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts#L68-L117)
### Asynchronous Settlement & Charge Processing
The charge-succeeded cron route parses incoming invoice payloads, updates payout methods from partner defaults, and evaluates whether funds have settled for card payments. If funds are not immediately available, delayed payouts are scheduled using QStash with a 10-minute deduplication window and parallelism settings.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:23-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts#L23-L96), [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:20-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts#L20-L62)
> [!WARNING]
> Payout methods such as stablecoins, PayPal, and Tremendous require funds to be fully settled before queuing; otherwise, funds are not released to prevent fronting card charges subject to reversal.
Sources: [apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts:67-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/route.ts#L67-L96), [apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts:64-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/charge-succeeded/utils.ts#L64-L67)
### Stripe Connect Webhook Handlers
Stripe Connect webhooks capture balance updates, payout completions, and payout failures, pushing payloads into dedicated QStash queues for asynchronous processing.
Sources: [apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts:5-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts#L5-30), [apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts:5-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts#L5-36), [apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts:5-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts#L5-34)
| Webhook Event / Handler | QStash Queue Name | Target Cron Route | Action Performed |
| :--- | :--- | :--- | :--- |
| `balanceAvailable` / `AccountExternalAccountUpdated` | `handle-balance-available` | `/api/cron/payouts/balance-available` | Retrieves Stripe balance, checks dust/pending amounts, creates Stripe payout, and updates matching payouts. |
| `payoutPaid` | `handle-payout-paid` | `/api/cron/payouts/payout-paid` | Updates payout status to `"completed"` with trace ID and notifies partner. |
| `payoutFailed` | `handle-payout-failed` | `/api/cron/payouts/payout-failed` | Receives failure metadata and updates disconnected or errored bank accounts. |
Sources: [apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts:5-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/balance-available.ts#L5-30), [apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts:5-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-paid.ts#L5-36), [apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts:5-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/stripe/connect/webhook/payout-failed.ts#L5-34), [apps/web/app/ee/api/cron/payouts/balance-available/route.ts:26-145](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/balance-available/route.ts#L26-145), [apps/web/app/ee/api/cron/payouts/payout-paid/route.ts:21-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/payout-paid/route.ts#L21-50)
### Partner Email Notifications
Email templates built with React Email notify partners across key lifecycle events, including withdrawal initiation, successful transfer completion, and action required for invalid bank accounts.
Sources: [apps/web/app/ee/api/cron/payouts/balance-available/route.ts:7-124](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/balance-available/route.ts#L7-124), [apps/web/app/ee/api/cron/payouts/payout-paid/route.ts:3-66](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/payout-paid/route.ts#L3-66), [packages/email/src/templates/partner-payout-processed.tsx:23-220](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-payout-processed.tsx#L23-220)
> [!TIP]
> Currencies such as HUF and TWD are validated to ensure balances are evenly divisible by 100, skipping dust amounts under 1 unit while requeuing checks if a positive pending balance exists.
Sources: [apps/web/app/ee/api/cron/payouts/balance-available/route.ts:63-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/cron/payouts/balance-available/route.ts#L63-89)
## Related
- [[Commission Rules and Rewards]]
- [[Stripe Billing and Webhooks]]
---
## Technical docs: POST Verify partner identity
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/verifyadminpartneridentity
## Parameters
## Responses
## Try It
---
## Technical docs: Partner Portal and Onboarding
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/partner-portal-and-onboarding
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/partners.dub.co/dashboard/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/referrals/page.tsx)
- [apps/web/app/ee/api/partners/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts)
- [apps/web/lib/middleware/partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/lib/partner-referrals/utils.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/utils.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/partnerId/links/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/%5BpartnerId%5D/links/page.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/app/ee/api/embed/referrals/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/links/route.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/links/route.ts)
- [apps/web/app/api/user/referrals-token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts)
- [apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts)
- [apps/web/app/api/tokens/embed/referrals/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/embed/referrals/route.ts)
- [apps/web/app/ee/partners.dub.co/auth-login-register/generic/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/layout.tsx)
- [apps/web/lib/actions/partners/generate-stripe-account-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts)
- [apps/web/ui/layout/sidebar/dub-partners-popup.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/dub-partners-popup.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/auth.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/page-client.tsx)
- [apps/web/ui/modals/partner-link-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/partner-link-modal.tsx)
- [apps/web/app/ee/partners.dub.co/onboarding/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx)
- [apps/web/app/ee/partners.dub.co/auth-login-register/partner-banner.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/partner-banner.tsx)
- [apps/web/app/ee/partners.dub.co/auth-login-register/side-panel.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/side-panel.tsx)
- [apps/web/app/ee/partners.dub.co/auth-other/invite/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/partners/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/page.tsx)
- [apps/web/app/app.dub.co/dashboard/account/settings/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page-client.tsx)
- [apps/web/lib/api/partners/generate-partner-link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/partnerId/links/referral-links.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/%5BpartnerId%5D/links/referral-links.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/links/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/links/page-client.tsx)
- [apps/web/app/ee/partners.dub.co/apply/programSlug/default/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/layout.tsx)
- [packages/email/src/templates/welcome-email-partner.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/welcome-email-partner.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/program-empty-state.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/program-empty-state.tsx)
## Overview
The partner portal and onboarding subsystem on `partners.dub.co` provides a comprehensive, multi-tenant environment where affiliates and referrers can apply to programs, configure custom tracking and payout channels, and monitor their performance. It manages session security, handles automated profile onboarding workflows, generates optimized referral and discount links, and embeds performance metrics directly into partner workflows.
Sources: [apps/web/lib/middleware/partners.ts:1-123](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L1-L123), [apps/web/lib/partner-referrals/utils.ts:1-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/utils.ts#L1-L22)
## Partner Routing and Authentication Middleware
### Overview
The request handling on `partners.dub.co` is driven by an edge middleware that inspects inbound requests, resolves token-based user sessions, enforces authentication policies across sensitive routes, and manages partner onboarding redirections. When requests arrive, path parsing extracts the target route, search parameters, and full path context before executing authorization checks against predefined path groupings.
Sources: [apps/web/lib/middleware/partners.ts:1-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L1-L33)
### Middleware Execution Walkthrough
The `PartnersMiddleware` function executes a multi-step evaluation sequence for every request hitting the partners domain:
1. `parse(req)` — Extracts `path`, `fullPath`, `searchParamsObj`, and `searchParamsString` from the inbound request.
2. `getUserViaToken(req)` — Resolves the user session via authentication tokens.
3. `partnersMarketplaceRedirects(path, searchParamsObj)` — Checks legacy marketplace paths and issues a `301` permanent redirect if a match is found.
4. `partnersProgramRedirects(path)` — Evaluates legacy program redirect rules.
5. Authentication and Enrollment Checks — If no user session exists and `isAuthenticatedPath` is true, unauthenticated users attempting to access `/programs/` paths are redirected to `/${programSlug}/login`, while other protected routes redirect to `/login?next=...` with open-redirect protection via `isValidInternalRedirect`.
Sources: [apps/web/lib/middleware/partners.ts:25-103](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L25-L103)
> [!WARNING]
> When handling the `?next=` query parameter, `isValidInternalRedirect` must validate the target path against the current request URL to prevent open redirect vulnerabilities, excluding `/onboarding` paths to guarantee proper enrollment completion.
> Sources: [apps/web/lib/middleware/partners.ts:91-102](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L91-L102)
### Protected Paths and Authentication Rules
The middleware enforces authentication across a strict set of application routes. Requests targeting any path starting with these prefixes require a valid user session and an associated partner profile ID.
| Path Prefix / Route | Purpose / Description |
|---------------------|----------------------|
| `/programs` | Affiliate programs listing and management |
| `/marketplace` | Partner marketplace discovery |
| `/onboarding` | Partner registration and profile setup |
| `/settings` | Partner account and notification settings |
| `/profile` | User and partner profile management |
| `/messages` | Communications and partner updates |
| `/payouts` | Earnings, banking, and payout configuration |
| `/account` | Account details and security |
| `/invite` | Partner profile invitation acceptance |
| `/rewind` | Historical earnings and year-in-review summaries |
Sources: [apps/web/lib/middleware/partners.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L12-L23)
> [!NOTE]
> Authenticated users who lack a `defaultPartnerId` and are not currently visiting `/onboarding`, `/account`, or an invite route (`/invite`) are automatically redirected to `/onboarding` to complete their profile setup.
> Sources: [apps/web/lib/middleware/partners.ts:75-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/partners.ts#L75-L89)
### Client-Side Session and Profile Authorization
Client-side rendering routes use specialized wrapper components such as `PartnerProfileAuth` and `AcceptPartnerInvitePage` to verify SWR session states and handle profile error codes.
```tsx
export function PartnerProfileAuth({ children }: { children: ReactNode }) {
const searchParams = useSearchParams();
const { loading: sessionLoading } = useRefreshSession("defaultPartnerId");
const { partner, error } = usePartnerProfile();
useEffect(() => {
const error = searchParams?.get("error");
if (error) {
toast.error(ERROR_CODES[error] || error);
}
}, [searchParams]);
const loading = sessionLoading || (!partner && !error);
if (loading) {
return ;
}
if (!loading && error && error.status === 404) {
redirect("/onboarding");
}
return children;
}
```
Sources: [apps/web/app/ee/partners.dub.co/dashboard/auth.tsx:1-46](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx#L1-L46)
The partner profile authentication layer recognizes specific error codes when connecting external payout channels or checking authorization status.
| Error Code Key | Error Message Displayed |
|----------------|-------------------------|
| `unauthorized` | Unauthorized. You must be logged in https://partners.dub.co to continue. |
| `partner_not_found` | Partner profile not found. |
| `invalid_state` | Invalid or expired state. Please try again from the beginning. |
| `paypal_email_not_verified` | PayPal email address is not verified. Please verify your email address in PayPal and try again. |
| `paypal_account_already_in_use` | The PayPal account you're trying to connect is already in use by another partner. Please use a different PayPal account. |
Sources: [apps/web/app/ee/partners.dub.co/dashboard/auth.tsx:10-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/auth.tsx#L10-L20)
## Partner Program Application and Onboarding
### Overview
Partner program application pages and onboarding layouts govern the entry point for prospective affiliates on `partners.dub.co`. The system handles dynamic program slugs, static parameter generation, and multi-step welcome sequences via React Email templates.
Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/layout.tsx:1-110](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/layout.tsx#L1-L110), [packages/email/src/templates/welcome-email-partner.tsx:1-123](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/welcome-email-partner.tsx#L1-L123)
### Program Landing Pages and Dynamic Routing
The application layout for dynamic program slugs resolves partner group data and constructs page metadata, falling back to default partner groups when no group slug is provided.
```typescript
export async function generateMetadata(props: {
params: Promise<{ programSlug: string; groupSlug?: string }>;
}) {
const { programSlug, groupSlug } = await props.params;
const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;
const program = await getProgram({
slug: programSlug,
groupSlug: partnerGroupSlug,
});
if (!program) {
notFound();
}
return constructMetadata({
title: `${program.name} Affiliate Program`,
description: `Join the ${program.name} affiliate program and ${
program.rewards && program.rewards.length > 0
? formatRewardDescription(program.rewards[0]).toLowerCase()
: "earn commissions"
} by referring ${program.name} to your friends and followers.`,
image: `${APP_DOMAIN}/api/og/program?slug=${program.slug}${groupSlug ? `&groupSlug=${groupSlug}` : ""}`,
canonicalUrl: `${PARTNERS_DOMAIN}/${program.slug}`,
});
}
```
Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/layout.tsx:12-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/layout.tsx#L12-L38)
Static parameter generation queries the program slug fetcher to pre-render partner application routes for static site generation.
```typescript
export async function generateStaticParams() {
const programs = await getProgramSlugs();
return programs.map((program) => ({
programSlug: program.slug,
groupSlug: DEFAULT_PARTNER_GROUP.slug,
}));
}
```
Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/layout.tsx:40-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/layout.tsx#L40-L47)
> [!WARNING]
> If `getProgram` returns a null or undefined program object for a given `programSlug`, the layout immediately triggers Next.js's `notFound()` helper, returning a 404 response.
> Sources: [apps/web/app/ee/partners.dub.co/apply/programSlug/default/layout.tsx:58-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(apply)/%5BprogramSlug%5D/(default)/layout.tsx#L58-L62)
### Onboarding Layout and UI Structure
The partner onboarding layout establishes a responsive container featuring an absolute SVG background grid, aurora gradient effects, wordmark branding, and a signed-in user hint component.
```tsx
export default function PartnerOnboardingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
Partners
{children}
>
);
}
```
Sources: [apps/web/app/ee/partners.dub.co/onboarding/layout.tsx:8-70](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(onboarding)/layout.tsx#L8-L70)
### Partner Welcome Email Sequence
Upon completing onboarding, partners receive a structured welcome email generated via React Email. The sequence outlines four explicit onboarding steps linking to target URLs within the Dub ecosystem.
| Step Number | Action Title | Target URL | Purpose |
|-------------|--------------|------------|---------|
| 1 | Complete your partner profile | `https://ship.dub.co/partner-profile` | Fill out partner profile and verify social platforms |
| 2 | Apply to our partner network | `https://ship.dub.co/join-network` | Unlock access to the program marketplace |
| 3 | Join a program | `https://ship.dub.co/marketplace` | Apply to specific brand programs and earn commissions |
| 4 | Set up payouts | `https://ship.dub.co/connect-payouts` | Connect a payout method to receive referral earnings |
Sources: [packages/email/src/templates/welcome-email-partner.tsx:51-114](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/welcome-email-partner.tsx#L51-L114)
## Partner Link Generation and Validation
### Referral Link Creation and Key Derivation
Partner links are generated programmatically or via UI workflows by mapping partner attributes to short link keys and processing them through link creation APIs. The core function `derivePartnerLinkKey()` evaluates input parameters in a specific fallback sequence: if an explicit `key` is provided, it is returned immediately; otherwise, the function checks for a `username`, falls back to slugified partner `name`, and finally slugifies the local part of the partner's `email`.
Sources: [apps/web/lib/api/partners/generate-partner-link.ts:16-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L16-L40)
When building default partner keys in multi-link scenarios, `buildPartnerDefaultLinkKey()` wraps the derived slug with optional prefix formatting and appends a randomized 4-character nanoid suffix if multiple default links exist for the partner group.
Sources: [apps/web/lib/api/partners/generate-partner-link.ts:46-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L46-L74)
During batch or individual link generation in `generatePartnerLink()`, key collision handling executes a retry loop: if `processLink()` returns a `conflict` error starting with `"Duplicate key"`, a randomized suffix is appended to `currentKey`, and link processing retries until successful.
Sources: [apps/web/lib/api/partners/generate-partner-link.ts:91-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L91-L164)
### Destination Validation and UTM Templates
When a partner creates or submits a custom destination URL, the system validates the URL against group settings and additional link configurations. In `PartnerLinkModalContent`, destination domains are evaluated against any `additionalLinks` defined in the partner group. If additional links exist, their domains form the allowed destination list; otherwise, the primary program URL's domain is used.
Sources: [apps/web/ui/modals/partner-link-modal.tsx:186-200](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/partner-link-modal.tsx#L186-L200)
Partner endpoints like `POST /api/partners/links`, `POST /api/embed/referrals/links`, and `POST /api/partner-profile/programs/[programId]/links` enforce enrollment status checks and max link limits (`group.maxPartnerLinks`) before invoking `validatePartnerLinkUrl()`.
Sources: [apps/web/app/ee/api/partners/links/route.ts:94-124](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L94-L124), [apps/web/app/ee/api/embed/referrals/links/route.ts:25-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/links/route.ts#L25-L56), [apps/web/app/ee/api/partner-profile/programs/programId/links/route.ts:79-133](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/links/route.ts#L79-L133)
Once a link passes core processing, `applyGroupUtmToLink()` enriches the link payload with standardized UTM parameters defined by the partner group's UTM template and the partner's name.
Sources: [apps/web/app/ee/api/partners/links/route.ts:189-194](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L189-L194), [apps/web/app/ee/api/embed/referrals/links/route.ts:135-139](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/links/route.ts#L135-L139), [apps/web/app/ee/api/partner-profile/programs/programId/links/route.ts:175-179](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/links/route.ts#L175-L179)
### AppsFlyer Mobile Deep-Linking Integration
For mobile attribution, `generatePartnerLink()` checks whether the processed destination URL is an AppsFlyer tracking URL using `isAppsFlyerTrackingUrl()`. When matching parameters are supplied, `applyAppsFlyerParameters()` injects attribution tokens into the URL query string, passing context objects containing `partnerName` and `partnerLinkKey`.
Sources: [apps/web/lib/api/partners/generate-partner-link.ts:147-161](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/generate-partner-link.ts#L147-L161)
### API Endpoints for Partner Links
| Route Path | HTTP Method | Handler / Auth Wrapper | Core Action |
|------------|-------------|------------------------|-------------|
| `/api/partners/links` | GET | `withWorkspace` | Retrieves partner links within a workspace program, optionally including reward fields based on search parameters. |
| `/api/partners/links` | POST | `withWorkspace` | Creates a partner link with optional link-level reward overrides and workspace plan capability checks. |
| `/api/embed/referrals/links` | GET | `withReferralsEmbedToken` | Fetches embedded partner links authenticated via referral embed tokens. |
| `/api/embed/referrals/links` | POST | `withReferralsEmbedToken` | Creates a partner link via the embed widget, respecting group link limits and emitting workspace webhooks. |
| `/api/partner-profile/programs/[programId]/links` | GET | `withPartnerProfile` | Returns partner links in a specific program enriched with resolved reward rules and discount codes. |
| `/api/partner-profile/programs/[programId]/links` | POST | `withPartnerProfile` | Generates a new partner link directly from the partner profile portal interface. |
Sources: [apps/web/app/ee/api/partners/links/route.ts:34-223](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L34-L223), [apps/web/app/ee/api/embed/referrals/links/route.ts:19-159](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/links/route.ts#L19-L159), [apps/web/app/ee/api/partner-profile/programs/programId/links/route.ts:19-186](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/links/route.ts#L19-L186)
> [!WARNING]
> When creating partner-level links via `POST /api/partners/links`, if any link-level reward IDs (`clickRewardId`, `leadRewardId`, `saleRewardId`, `discountId`) are specified, the workspace plan capability `canUseAdvancedRewardLogic` must evaluate to true, or the request will fail with a forbidden error.
> Sources: [apps/web/app/ee/api/partners/links/route.ts:197-214](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partners/links/route.ts#L197-L214)
## Embedded Referrals and Token Distribution
### Overview
Embedded referral widgets allow partners to manage their referral links, resources, and payout settings directly inside third-party applications via token-authenticated embeds. The client entry point (`ReferralsPageClient`) fetches an immutable user referrals token from `/api/user/referrals-token` and renders ``.
Sources: [apps/web/app/app.dub.co/dashboard/account/settings/referrals/page-client.tsx:11-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page-client.tsx#L11-L48)
### Token Generation and Partner Enrollment Flow
The token issuance pipeline validates session state, inspects workspace eligibility, and automatically provisions or links partner profiles.
```mermaid
sequenceDiagram
participant Client
participant API as /api/tokens/embed/referrals
participant DB as Prisma DB
participant Dub as Dub SDK
Client->>API: POST request with tenantId & partner metadata
API->>DB: Query programEnrollment by partnerId or tenantId
alt Enrollment missing & partnerProps provided
API->>DB: Find partner by email
alt Partner missing or not enrolled
API->>DB: createAndEnrollPartner()
end
end
API->>Dub: referralsEmbedToken.create()
Dub-->>API: Return token payload
API-->>Client: Return 201 with ReferralsEmbedTokenSchema
```
Sources: [apps/web/app/api/tokens/embed/referrals/route.ts:16-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/embed/referrals/route.ts#L16-L117)
When free workspaces request tokens via `/api/user/referrals-token`, the router checks whether the user has earned commissions, is banned, or joined within the last 30 days. Eligible tenants receive a public token generated by `dub.embedTokens.referrals()`.
Sources: [apps/web/app/api/user/referrals-token/route.ts:40-72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts#L40-L72)
### Quickstart Widget Architecture
The `ReferralsEmbedQuickstart` component presents an interactive carousel or grid containing quickstart actions, customized by program configuration data parsed via `programEmbedSchema`.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:22-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx#L22-L33)
| Quickstart Item | Target Tab / Action | Condition / Behavior |
|-----------------|---------------------|----------------------|
| **Share your link** | Copies `links[0]` or navigates to `"Links"` tab | Uses `constructPartnerLink()` with the default group and link. |
| **Program resources** | `"Resources"` tab | Disabled if `hasResources` is false. |
| **Browse the FAQ** | `"FAQ"` tab | Rendered when `programEmbedData.hideEarnings` is true. |
| **Receive earnings** | `"Settings"` tab or external URL | Evaluates Tremendous country support and payout history. |
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:37-159](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx#L37-L159)
> [!WARNING]
> The receive earnings CTA displays a disabled tooltip preventing withdrawals when `earnings.upcoming === 0 && earnings.paid === 0`, stating that users can withdraw funds once they complete at least one sale.
> Sources: [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:35-136](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx#L35-L136)
## Payout Setup and Stripe Integration
### Overview
Partner payout and banking setup relies on server actions that authenticate partner requests, verify role-based permissions (`payout_settings.update`), check country-specific eligibility using `getPayoutMethodsForCountry`, and provision or link appropriate Stripe accounts (Stripe Connect or Stripe Recipient accounts) before generating valid redirection links for onboarding or updates.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:1-83](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L1-L83), [apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts:1-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts#L1-L76)
### Stripe Connect Account Execution Walkthrough
The Stripe Connect onboarding and link generation flow proceeds through explicit validation and provisioning steps before evaluating account submission status:
1. `authPartnerActionClient.action()` — Intercepts the request and injects the authenticated `partner` and `partnerUser` context.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:12-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L12-L14)
2. `throwIfNoPermission()` — Validates that `partnerUser.role` holds the `payout_settings.update` permission.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:16-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L16-L19)
3. `getPayoutMethodsForCountry()` — Verifies that `partner.country` supports `PartnerPayoutMethod.connect`, throwing an error displaying the localized country name from `COUNTRIES` if unsupported.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:35-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L35-L43)
4. `createConnectedAccount()` — Provisions a new connected account via Stripe if `partner.stripeConnectId` is missing, persisting the resulting ID via `prisma.partner.update()`.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:21-57](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L21-L57)
5. `stripe.accounts.retrieve()` — Fetches the live account record using `partner.stripeConnectId`.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L66)
6. Branch evaluation: If `account.details_submitted` is true, calls `stripe.accounts.createLoginLink()`; otherwise, calls `stripe.accountLinks.create()` with `type: "account_onboarding"` and `collect: "eventually_due"`.
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:68-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L68-L77)
> [!WARNING]
> Both Stripe actions strictly require partners to have both a valid email and a configured country set in `partners.dub.co/settings`; omitting either throws an immediate descriptive error preventing account generation.
> Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:23-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L23-L34), [apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts:23-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts#L23-L34)
### Stripe Recipient Account Actions and Validation
The recipient account generation flow handles alternative payout methods such as stablecoins by checking country support against `PartnerPayoutMethod.stablecoin`.
```mermaid
sequenceDiagram
participant Client
participant Action as generateStripeRecipientAccountLink
participant DB as Prisma DB
participant Stripe as Stripe API
Client->>Action: Invoke action
Action->>Action: throwIfNoPermission("payout_settings.update")
alt partner.stripeRecipientId is missing
Action->>Action: Validate email & country presence
Action->>Action: getPayoutMethodsForCountry()
Action->>Stripe: createStripeRecipientAccount()
Stripe-->>Action: Recipient account object
Action->>DB: prisma.partner.update(stripeRecipientId)
Action->>Action: useCase = "account_onboarding"
else partner.stripeRecipientId exists
Action->>Action: useCase = "account_update"
end
Action->>Stripe: createStripeRecipientAccountLink()
Stripe-->>Action: Account link object
Action-->>Client: Return { url }
```
Sources: [apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts:12-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts#L12-L75)
### Payout Integration Parameters Reference
| Integration Parameter / Constant | Target Function / SDK | Value / Structure | Purpose |
|-----------------------------------|-----------------------|-------------------|---------|
| `payout_settings.update` | `throwIfNoPermission` | Role permission string | Guards payout modification endpoints against unauthorized users. |
| `PartnerPayoutMethod.connect` | `getPayoutMethodsForCountry` | Enum value | Validates country eligibility for standard Stripe Connect payouts. |
| `PartnerPayoutMethod.stablecoin` | `getPayoutMethodsForCountry` | Enum value | Validates country eligibility for stablecoin recipient payouts. |
| `account_onboarding` | `createStripeRecipientAccountLink` / `stripe.accountLinks.create` | Action use case / link type string | Triggers onboarding flow for newly created accounts. |
| `account_update` | `createStripeRecipientAccountLink` | Action use case string | Triggers update flow for already provisioned accounts. |
| `eventually_due` | `stripe.accountLinks.create` | `collect` option | Specifies requirement collection behavior during onboarding. |
Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:18-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L18-L76), [apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts:18-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-recipient-account-link.ts#L18-L70)
> [!TIP]
> When generating standard Stripe Connect links, the action dynamically inspects `account.details_submitted`; if true, it provisions a direct login link via `stripe.accounts.createLoginLink()`, avoiding redundant onboarding wizard steps for fully verified accounts.
> Sources: [apps/web/lib/actions/partners/generate-stripe-account-link.ts:68-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/generate-stripe-account-link.ts#L68-L70)
## Earnings Dashboard and Network Referrals
### Overview
The partner portal analytics and telemetry layer integrates timeseries performance tracking, discount code displays, network referral monitoring, and webhook-driven customer support telemetry via Plain. Partners visualize their earnings, clicks, leads, and sales via synchronized timeseries performance charts powered by SWR hooks like `usePartnerEarningsTimeseries` and `usePartnerAnalytics`. Referral performance is accompanied by granular reward configurations and discount code displays.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/referrals/page.tsx:242-257](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/referrals/page.tsx#L242-L257), [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/page-client.tsx:12-213](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/page-client.tsx#L12-L213), [apps/web/app/api/callback/plain/partner/route.ts:1-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L1-L122)
### Plain Customer Webhook Telemetry Execution Flow
Customer support integrations rely on Plain webhook callbacks authenticated via the `X-Plain-Webhook-Secret` header. When a support ticket or customer view requests partner telemetry, the webhook route executes a precise verification and database lookup chain.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-88](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L88)
```mermaid
sequenceDiagram
participant Plain as Plain Webhook
participant Route as POST /api/callback/plain/partner
participant Prisma as Prisma DB
participant SDK as Plain SDK
Plain->>Route: POST request with X-Plain-Webhook-Secret
Route->>Route: Verify token matches process.env.PLAIN_WEBHOOK_SECRET
Route->>Route: Parse body via plainCallbackSchema
alt customer.externalId is missing
Route->>Prisma: prisma.user.findUnique({ email })
alt user found
Route->>Prisma: upsertPlainCustomer({ id, name, email })
Route-->>Route: Set customer.externalId = user.id
else user not found
Route-->>Plain: Return empty container "No user found."
end
end
Route->>Prisma: prisma.partner.findFirst({ users.some: userId }, include: programs)
alt partnerProfile found
Route->>SDK: plain.addCustomerToCustomerGroups({ customerId, groupKey: "partners.dub.co" })
Route-->>Plain: Return JSON UI cards (ID, Name, Country, Stripe, Payouts, Top Programs)
else partnerProfile missing
Route-->>Plain: Return empty container "No partner profile found."
end
```
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L122)
> [!WARNING]
> If a Plain customer payload lacks an `externalId`, the webhook fallback queries the database by email address to automatically resolve and bind the user ID, failing with an empty container response if the email does not exist in the database.
> Sources: [apps/web/app/api/callback/plain/partner/route.ts:31-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L31-L47)
### Plain Webhook Telemetry UI Components Reference
| UI Component / Field | SDK Method / Source Variable | Value / Configuration | Purpose |
|----------------------|------------------------------|------------------------|---------|
| `partner` card key | Response JSON card definition | `{ key: "partner", components: [...] }` | Root container grouping partner telemetry elements for Plain support views. |
| `partners.dub.co` | `plain.addCustomerToCustomerGroups` | Customer group identifier string | Automatically assigns verified partners to the designated customer group in Plain. |
| `Dub Admin View` | `uiComponent.linkButton` | `https://admin.dub.co/partners/network?search={id}&partnerId={id}` | Deep-links support agents directly to the internal Dub admin view for the partner. |
| `Payouts Enabled (UTC)` | `uiComponent.badge` | Green badge if `payoutsEnabledAt` is set, Red ("No") otherwise | Displays timestamp-aware payout activation status in UTC format. |
| `Stripe Recipient Account` | `uiComponent.linkButton` | `https://dashboard.stripe.com/global-payouts/recipients/{id}` | Direct link to Stripe global payouts recipient dashboard. |
| `Stripe Express Account` | `uiComponent.linkButton` | `https://dashboard.stripe.com/connect/accounts/{id}` | Direct link to Stripe Connect express account management. |
Sources: [apps/web/app/api/callback/plain/partner/route.ts:112-249](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L112-L249)
### Network Referral Links and Rewards Monitoring
Partners enrolled in the Dub Network can monitor network referrals, track referred partner counts, and view cumulative commission earnings through dedicated dashboard statistics components. Referral links are constructed using `constructPartnerReferralLink` and support custom query parameters such as `?via=username`.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/referrals/page.tsx:242-372](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/referrals/page.tsx#L242-L372), [apps/web/lib/partner-referrals/utils.ts:8-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/utils.ts#L8-L22)
> [!TIP]
> Network referral links can target any page on the partners domain by appending `?via={username}` directly to the destination URL, allowing partners to deep-link custom landing pages while preserving referral attribution.
> Sources: [apps/web/app/ee/partners.dub.co/dashboard/referrals/page.tsx:350-369](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/referrals/page.tsx#L350-L369)
## Related
- [[Partner Program Management]]
- [[Embeddable Referral Widgets]]
---
## Technical docs: POST Delete partner account
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/deleteadminpartneraccount
## Request Body
Partner email and deletion flag
## Responses
## Try It
---
## Technical docs: Embeddable Referral Widgets
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/embeddable-referral-widgets
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/app.dub.co/embed/referrals/token.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/token.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/dynamic-height-messenger.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/dynamic-height-messenger.tsx)
- [apps/web/app/api/tokens/embed/referrals/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/embed/referrals/route.ts)
- [apps/web/app/app.dub.co/dashboard/account/settings/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page-client.tsx)
- [apps/web/app/ee/api/embed/referrals/token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/token/route.ts)
- [apps/web/app/api/user/referrals-token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts)
- [packages/embeds/react/src/embed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/links.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/links.tsx)
- [apps/web/app/app.dub.co/embed/support-chat/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/embed/support-chat/page.tsx)
- [packages/embeds/core/src/core.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts)
- [apps/web/app/app.dub.co/dashboard/account/settings/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page.tsx)
- [apps/web/ui/partners/groups/design/previews/embed-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/groups/design/previews/embed-preview.tsx)
- [apps/web/lib/embed/referrals/token-class.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/token-class.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/get-referrals-embed-data.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/faq.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/activity.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/bounties/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/index.tsx)
- [apps/web/lib/middleware/embed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/embed.ts)
- [apps/web/ui/layout/sidebar/refer-button.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/refer-button.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/referrals/page.tsx)
- [apps/web/lib/embed/referrals/auth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/auth.ts)
- [apps/web/app/app.dub.co/embed/support-chat/dynamic-height-messenger.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/embed/support-chat/dynamic-height-messenger.tsx)
- [apps/web/lib/openapi/embed-tokens/index.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/embed-tokens/index.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx)
- [apps/web/ui/support/embedded-chat.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/embedded-chat.tsx)
- [packages/embeds/core/src/types.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/types.ts)
- [packages/embeds/react/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/index.ts)
## Overview
Embeddable Referral Widgets provide a portable interface that allows host applications to integrate partner program dashboards directly into their user settings or interface via secure iframe encapsulation. The system addresses the challenge of securely exposing affiliate marketing tools, link tracking stats, and reward management outside the primary platform without sacrificing authentication or data integrity. Key architectural decisions include server-side token generation backed by Upstash Redis persistence, client SDK DOM injection with automated iframe sizing via postMessage synchronization, and streamlined React wrapper integration. Adjacent components such as middleware routing, enrollment verification, and centralized API endpoints coordinate to hydrate partner context and deliver seamless navigation across reward management and payout configurations.
Sources: [packages/embeds/core/src/core.ts:5-103](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L5-L103), [packages/embeds/react/src/embed.tsx:1-40](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L1-L40), [apps/web/app/api/tokens/embed/referrals/route.ts:16-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/embed/referrals/route.ts#L16-L122), [apps/web/lib/embed/referrals/token-class.ts:13-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/token-class.ts#L13-L33)
## Client SDK Architecture and Rendering
### Overview
The client SDK architecture coordinates core DOM manipulation logic and React wrapper integration to mount referral widgets securely inside host applications. The initialization sequence bridges vanilla TypeScript modules with React component lifecycles using reference hooks and dynamic element creation.
Sources: [packages/embeds/core/src/core.ts:12-142](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L12-L142), [packages/embeds/react/src/embed.tsx:14-40](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L14-L40)
### Call-Chain Execution Walkthrough
When a host application mounts an embed, initialization executes a strict call sequence: `DubEmbed` component renders → `DubEmbedInner` fires `useEffect` → `init()` instantiates `DubEmbed` → `renderEmbed()` creates or reuses the DOM container → `createIframe()` builds the iframe element with URL parameters.
```mermaid
sequenceDiagram
participant React as DubEmbed / Inner
participant Core as init() / DubEmbed
participant DOM as document / window
React->>Core: init({ root, token, data, ... })
Core->>Core: new DubEmbed(options)
Core->>Core: renderEmbed()
Core->>DOM: getElementById(DUB_CONTAINER_ID)
Core->>DOM: createElement("div") (if missing)
Core->>Core: createIframe(iframeUrl, token, options)
Core->>DOM: iframe.appendChild / window.addEventListener("message")
Core->>DOM: root.appendChild(container)
Core->>React: returns { destroy }
Note over React,Core: On unmount / option change
React->>Core: destroy()
Core->>DOM: getElementById(DUB_CONTAINER_ID)?.remove()
```
Sources: [packages/embeds/core/src/core.ts:16-102](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L16-L102), [packages/embeds/react/src/embed.tsx:14-40](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L14-L40)
> [!WARNING]
> If `token` is omitted during initialization, `renderEmbed` logs an error to the console and immediately returns `null`, preventing the container and iframe from mounting.
> Sources: [packages/embeds/core/src/core.ts:36-39](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L36-L39)
### Configuration Options and Message Types
The core SDK accepts explicit configuration options and listens for specific incoming message events from the hosted iframe via `window.addEventListener`.
| Option / Event | Type / Value | Purpose |
| :--- | :--- | :--- |
| `token` | `string` | Required link authentication token for embed data access. |
| `root` | `HTMLElement` | Target DOM element where the embed container is appended (`document.body` by default). |
| `containerStyles` | `Partial` | Custom CSS overrides for the root embed container. |
| `data` | `"referrals" \| "analytics"` | Specifies the target data type for the widget (`"referrals"` resolves to `/embed/referrals`). |
| `theme` | `"light" \| "dark" \| "system"` | Theme preference passed as a URL search parameter to the iframe. |
| `themeOptions` | `{ backgroundColor?: string }` | Additional theme customization options serialized into JSON. |
| `onError` | `(error: Error) => void` | Callback triggered when an `ERROR` message is received from the iframe. |
| `ERROR` event | `IframeMessage` | Handles error codes and messages dispatched from the frame. |
| `PAGE_HEIGHT` event | `IframeMessage` | Dynamically updates the embed container height upon receiving height updates. |
Sources: [packages/embeds/core/src/core.ts:30-89](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L30-L89), [packages/embeds/core/src/types.ts:9-51](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/types.ts#L9-L51)
### React Wrapper Integration
The React integration layer wraps the core imperative SDK inside declarative components. The `DubEmbed` component uses `memo` and delegates to `DubEmbedInner`, which assigns a unique identifier via `useId()`, sets up a `useRef`, and runs an `useEffect` hook that triggers `init()` and cleanup via `destroy()`.
```tsx
export const DubEmbed = memo(
({ token, data, options, ...rest }: DubEmbedProps) => (
),
);
```
Sources: [packages/embeds/react/src/embed.tsx:14-40](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L14-L40)
> [!NOTE]
> The `useEffect` dependency array serializes options using `JSON.stringify(options)` alongside the React `useId()` value to safely re-initialize the embed when configuration properties change without incurring reference equality bugs.
> Sources: [packages/embeds/react/src/embed.tsx:37-37](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L37-L37)
### Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Imperative core SDK wrapped in React `useEffect` | Allows the core widget logic to be consumed in vanilla JavaScript or any framework while offering a clean React component facade. | Requires manual serialization of options in React hooks (`JSON.stringify`) to track deep configuration updates accurately. |
| Global singleton container ID check (`DUB_CONTAINER_ID`) | Prevents duplicate container injection if multiple render cycles or strict mode mount instances concurrently. | Restricts a single page to running one active embed instance under the default container identifier. |
| Hostname-based environment resolution (`localhost`, `preview.dub.co`, `app.dub.co`) | Automatically routes iframe requests to the correct local, preview, or production domain without requiring explicit host configuration. | Ties SDK deployment behavior directly to specific hostname strings matching the primary platform domains. |
Sources: [packages/embeds/core/src/core.ts:32-55](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L32-L55), [packages/embeds/react/src/embed.tsx:27-38](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/src/embed.tsx#L27-L38)
## Token Generation and Authentication Flow
### Overview
The token generation and authentication subsystem governs how referral embed tokens are created, persisted in Upstash Redis, validated by server-side middleware, and used to authorize requests against program enrollments. The lifecycle begins when an authenticated client requests a token through either the workspace-scoped API (`POST /api/tokens/embed/referrals`), user-level onboarding routes (`GET /api/user/referrals-token`), or specialized authentication wrappers (`withReferralsEmbedToken`).
Sources: [apps/web/app/api/tokens/embed/referrals/route.ts:16-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/embed/referrals/route.ts#L16-L122), [apps/web/app/api/user/referrals-token/route.ts:11-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts#L11-L74), [apps/web/lib/embed/referrals/auth.ts:33-134](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/auth.ts#L33-L134)
### Token Generation and Persistence
The `ReferralsEmbedToken` class handles the creation and retrieval of public tokens. Tokens are generated using a unique identifier prefixed with `EMBED_PUBLIC_TOKEN_PREFIX` and persisted inside Upstash Redis with a strict time-to-live (`EMBED_PUBLIC_TOKEN_EXPIRY`).
```typescript
class ReferralsEmbedToken {
async create(props: ReferralsEmbedTokenProps) {
const publicToken = createId({
prefix: EMBED_PUBLIC_TOKEN_PREFIX,
});
await redis.set(publicToken, JSON.stringify(props), {
ex: EMBED_PUBLIC_TOKEN_EXPIRY,
nx: true,
});
return {
publicToken,
expires: new Date(Date.now() + EMBED_PUBLIC_TOKEN_EXPIRY * 1000),
};
}
async get(token: string) {
return await redis.get(token);
}
}
```
Sources: [apps/web/lib/embed/referrals/token-class.ts:13-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/token-class.ts#L13-L33)
> [!NOTE]
> The `nx: true` option in `redis.set` ensures that token creation fails if a key collision occurs, guaranteeing uniqueness across generated embed tokens.
> Sources: [apps/web/lib/embed/referrals/token-class.ts:19-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/token-class.ts#L19-L22)
### Authentication and Authorization Flow
The `withReferralsEmbedToken` wrapper secures API routes by extracting the bearer token from the `Authorization` header, validating it against Redis, enforcing rate limits, and fetching the associated program enrollment from the database.
```mermaid
sequenceDiagram
participant Client
participant Route as withReferralsEmbedToken
participant Redis as Upstash Redis
participant DB as Prisma PostgreSQL
Client->>Route: GET /api/embed/referrals/token (Authorization: Bearer )
Route->>Redis: referralsEmbedToken.get(embedToken)
Redis-->>Route: { programId, partnerId }
Route->>Redis: ratelimit(60, "1 m").limit(embedToken)
Redis-->>Route: { success, limit, remaining, reset }
Route->>DB: prisma.programEnrollment.findUniqueOrThrow(...)
DB-->>Route: { program, links, partnerGroup, ...programEnrollment }
Route->>Client: NextResponse.json(embedToken)
```
Sources: [apps/web/lib/embed/referrals/auth.ts:42-128](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/auth.ts#L42-L128)
### Middleware Route Authentication
Embed routes and token parameters are governed by `EmbedMiddleware`. Incoming requests are inspected for query parameters or support paths, and rewritten to internal application endpoints or redirected accordingly.
```typescript
export function EmbedMiddleware(req: NextRequest) {
const { path, searchParamsObj, fullPath } = parse(req);
if (path.startsWith("/embed/support-chat")) {
return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url));
}
if (searchParamsObj.token) {
return NextResponse.rewrite(new URL(`/app.dub.co${fullPath}`, req.url));
}
return NextResponse.redirect(new URL("/", req.url));
}
```
Sources: [apps/web/lib/middleware/embed.ts:4-17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/embed.ts#L4-L17)
### Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Storing token payloads directly in Upstash Redis | Provides sub-millisecond token resolution without requiring heavy relational database lookups for every session check. | Requires careful TTL management to ensure expired tokens are purged automatically. |
| Bearer token extraction via `Authorization` header | Aligns with standard HTTP REST authentication conventions for secure token transmission. | Relies on client-side headers being correctly attached by the iframe hosting wrapper. |
| Rate-limiting per token identifier (`ratelimit` via Redis) | Protects downstream program enrollment queries from abuse and brute-force inspection. | Adds latency checking overhead to every authenticated request. |
Sources: [apps/web/lib/embed/referrals/token-class.ts:19-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/token-class.ts#L19-L31), [apps/web/lib/embed/referrals/auth.ts:48-82](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/embed/referrals/auth.ts#L48-L82)
## Dynamic Resizing and Cross-Window Messaging
### Overview
The dynamic resizing and cross-window messaging subsystem synchronizes iframe dimensions between the embedded widget application and the host page. It relies on a bidirectional `window.postMessage` protocol combined with a `ResizeObserver` running inside the iframe document. When content changes inside the widget, height updates are dispatched to the parent window, which resizes the host container element accordingly.
Sources: [packages/embeds/core/src/core.ts:68-90](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L68-L90), [apps/web/app/ee/app.dub.co/embed/referrals/dynamic-height-messenger.tsx:5-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/dynamic-height-messenger.tsx#L5-L27)
### Iframe URL Generation and Parameters
When the `DubEmbed` core initializes an embed, it constructs an iframe URL appending parameters such as `token`, `theme`, `themeOptions`, and sets `dynamicHeight: "true"` to signal support for dynamic resizing scripts.
```typescript
const createIframe = (
iframeUrl: string,
token: string,
options: Pick,
): HTMLIFrameElement => {
const iframe = document.createElement("iframe");
const params = new URLSearchParams({
token,
...(options.theme ? { theme: options.theme } : {}),
...(options.themeOptions
? { themeOptions: JSON.stringify(options.themeOptions) }
: {}),
// Allows the iframe content to set overflow values and send height messages without affecting older embed scripts
dynamicHeight: "true",
});
iframe.src = `${iframeUrl}?${params.toString()}`;
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "none";
iframe.setAttribute("credentialssupport", "");
iframe.setAttribute("allow", "clipboard-write");
return iframe;
};
```
Sources: [packages/embeds/core/src/core.ts:105-131](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L105-L131)
### Height Observation and Message Dispatch
Inside the referral widget and support chat applications, `DynamicHeightMessenger` and `SupportChatDynamicHeightMessenger` lock the document body overflow to hidden and instantiate a `ResizeObserver` monitoring `document.body`. Every time the body dimensions shift, `update()` calculates `document.body.scrollHeight` and transmits a `PAGE_HEIGHT` message via `parent.postMessage`.
```typescript
export function DynamicHeightMessenger() {
useEffect(() => {
document.body.style.overflow = "hidden";
const update = () => {
const height = document.body.scrollHeight;
parent.postMessage(
{
originator: "Dub",
event: "PAGE_HEIGHT",
data: { height },
},
"*",
);
};
update();
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(document.body);
return () => {
resizeObserver.disconnect();
};
}, []);
return false;
}
```
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/dynamic-height-messenger.tsx:5-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/dynamic-height-messenger.tsx#L5-L30)
> [!NOTE]
> Both referral embeddings and support chat implementations use identical messenger mechanics, ensuring uniform behavior across distinct embedded components.
> Sources: [apps/web/app/app.dub.co/embed/support-chat/dynamic-height-messenger.tsx:5-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/embed/support-chat/dynamic-height-messenger.tsx#L5-L28)
### Host Event Listener and Container Resizing
The host page registers a `message` event listener on `window` inside `DubEmbed.renderEmbed()`. When an incoming message with `event: "PAGE_HEIGHT"` arrives, it targets the container element by `DUB_CONTAINER_ID` and updates its CSS height property to match the reported scroll height.
```typescript
// Listen the message from the iframe
window.addEventListener("message", (e) => {
const { data, event } = e.data as IframeMessage;
console.debug("[Dub] Iframe message", data);
switch (event) {
case "ERROR":
onError?.(
new EmbedError({
code: data?.code ?? "",
message: data?.message ?? "",
}),
);
break;
case "PAGE_HEIGHT": {
const container = document.getElementById(DUB_CONTAINER_ID);
if (container) container.style.height = `${data.height}px`;
break;
}
}
});
```
Sources: [packages/embeds/core/src/core.ts:68-90](https://github.com/blade47/dub/blob/HEAD/packages/embeds/core/src/core.ts#L68-L90)
## Widget Page Layout and Data Hydration
### Overview
The referral embed server-side entry point processes incoming requests inside Next.js Server Components, extracts query search parameters including `token`, `themeOptions`, and `dynamicHeight`, and hydrates the page client context. When the `ReferralsEmbedPage` component loads, it wraps execution in a `Suspense` boundary utilizing `EmbedInlineLoading` as a fallback, while `ReferralsEmbedRSC` coordinates data fetching via `getReferralsEmbedData(token)` before mounting `ReferralsEmbedPageClient`.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/page.tsx:9-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page.tsx#L9-L58)
### Enrollment Verification and Data Hydration
The `getReferralsEmbedData` asynchronous function validates the incoming token using `referralsEmbedToken.get(token)`. If either `programId` or `partnerId` is missing, it triggers a `notFound()` response. It subsequently calls `getProgramEnrollmentOrThrow` to load the partner enrollment record with nested relations including partner platforms, program metadata, links with associated link rewards, click rewards, lead rewards, sale rewards, referral rewards, custom rewards, discounts, and partner groups.
```typescript
export const getReferralsEmbedData = async (token: string) => {
const { programId, partnerId } = (await referralsEmbedToken.get(token)) ?? {};
if (!programId || !partnerId) {
notFound();
}
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId,
programId,
include: {
partner: {
select: {
id: true,
name: true,
email: true,
username: true,
country: true,
tremendousEmail: true,
defaultPayoutMethod: true,
platforms: {
select: {
type: true,
identifier: true,
verifiedAt: true,
},
},
},
},
program: {
select: {
id: true,
name: true,
slug: true,
domain: true,
defaultGroupId: true,
minPayoutAmount: true,
termsUrl: true,
embedData: true,
resources: true,
},
},
links: {
include: {
linkReward: {
include: {
clickReward: true,
leadReward: true,
saleReward: true,
discount: true,
},
},
},
},
partnerGroup: true,
clickReward: true,
leadReward: true,
saleReward: true,
referralReward: true,
customReward: true,
discount: true,
programPartnerTags: {
select: {
partnerTagId: true,
},
},
},
});
if (!programEnrollment || !programEnrollment.partnerGroup) {
notFound();
}
...
```
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/get-referrals-embed-data.ts:14-85](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts#L14-L85)
> [!WARNING]
> If a partner attempts to load an embed widget for a program where their enrollment is missing or inactive, `getProgramEnrollmentOrThrow` and subsequent null checks will throw or trigger a 404 error, preventing unauthorized rendering.
> Sources: [apps/web/app/ee/app.dub.co/embed/referrals/get-referrals-embed-data.ts:17-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts#L17-L19), [apps/web/app/ee/app.dub.co/embed/referrals/get-referrals-embed-data.ts:83-85](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts#L83-L85)
### Commissions Aggregation and Payout Metrics
Concurrently with partner bounty checks via `getBountiesForPartner`, `getReferralsEmbedData` queries the database for `Commission` records grouped by status, filtering for records with `earnings: { gt: 0 }` for the specific `programId` and `partnerId`. It aggregates partner link stats using `aggregatePartnerLinksStats(links)`.
```typescript
const { totalClicks, totalLeads, totalConversions } =
aggregatePartnerLinksStats(links);
const [commissions, bounties] = await Promise.all([
prisma.commission.groupBy({
by: ["status"],
_sum: {
earnings: true,
},
_count: {
id: true,
},
where: {
earnings: {
gt: 0,
},
programId,
partnerId,
},
}),
getBountiesForPartner(programEnrollment),
]);
```
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/get-referrals-embed-data.ts:100-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts#L100-L122)
### Client Hydration and Token Monitoring
Once data is returned from the server component, `ReferralsEmbedPageClient` parses resource and embed schemas, evaluates whether the partner has active embed access via `ACTIVE_ENROLLMENT_STATUSES`, and renders either an unapproved fallback view or the full dashboard wrapped inside `ReferralsEmbedDataProvider`.
```typescript
export const ReferralsReferralsEmbedToken = () => {
const token = useEmbedToken();
const { error } = useSWR<{ token: number }>(
"/api/embed/referrals/token",
(url) =>
fetcher(url, {
headers: {
Authorization: `Bearer ${token}`,
},
}),
{
revalidateOnFocus: true,
dedupingInterval: 30000,
keepPreviousData: true,
},
);
// Inform the parent if there's an error (Eg: token is expired)
useEffect(() => {
if (error) {
window.parent.postMessage(
{
originator: "Dub",
event: "ERROR",
data: error.info,
},
"*",
);
}
}, [error]);
return null;
};
```
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/token.tsx:8-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/token.tsx#L8-L41)
## Referral Management and Payout UI
### Overview
Referral management and payout UI within embedded widgets enables partners to generate affiliate links, view activity metrics, explore bounties, select payout methods, and view program FAQs. The host dashboard integrates these views by mounting client entrypoints like `ReferralsPageClient`, which verifies public embed tokens via SWR or renders empty states when tokens are missing.
Sources: [apps/web/app/app.dub.co/dashboard/account/settings/referrals/page-client.tsx:11-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/referrals/page-client.tsx#L11-L49)
### Affiliate Link Generation and Management
The link management UI toggles between the links list view (`ReferralsEmbedLinksList`) and the creation/update form (`ReferralsEmbedCreateUpdateLink`). Partners can manage their custom referral URLs using construct utilities.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/links.tsx:6-31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/links.tsx#L6-L31), [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx:1](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx#L1-L1)
### Activity Tracking and Analytics
The `ReferralsEmbedActivity` component renders aggregate performance metrics for clicks, leads, and conversions. When statistics are non-zero, it fetches composite analytics via SWR with a 1-year annual interval and composites timeseries data.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/activity.tsx:10-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx#L10-L40)
```typescript
const analyticsSearchParams = new URLSearchParams({
event: "composite",
groupBy: "timeseries",
interval: "1y",
saleType: "new",
});
const { data: analytics } = useSWR(
!isEmpty &&
`/api/embed/referrals/analytics?${analyticsSearchParams.toString()}`,
(url) =>
fetcher(url, {
headers: {
Authorization: `Bearer ${token}`,
},
}),
{
keepPreviousData: true,
dedupingInterval: 60000,
},
);
```
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/activity.tsx:20-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx#L20-L40)
### Reward Payouts and Payout Methods
Partners configure payout destinations through `ReferralsEmbedSettings`, which supports two primary payout methods: Tremendous gift cards and direct cash registrations.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:506-546](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx#L506-L546)
| Payout Method | Configuration Identifier | Minimum/Maximum Limits | Authentication Flow |
| :--- | :--- | :--- | :--- |
| **Gift Cards (Tremendous)** | `tremendous` | Min: `$10.00` (1000 cents), Max: `$10,000.00` | Email submission → OTP Request → 6-digit verification code input via `OTPInput` |
| **Cash** | Custom / External | Determined by program terms | External redirect to registration or SSO login endpoint |
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:6-7](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx#L6-L7), [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:246-277](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx#L246-L277), [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:509-543](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx#L509-L543)
> [!WARNING]
> Payout methods are mutually exclusive and permanent. Once a partner connects a default payout method (`partner.defaultPayoutMethod`), any other payout option is disabled with a tooltip explaining that multiple methods cannot be combined, and existing methods cannot be changed through the widget interface.
> Sources: [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:355-360](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/settings.tsx#L355-L360), [apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx:521-523](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/app.dub.co/embed/referrals/settings.tsx#L521-L523)
### Bounties and FAQ Embed Integration
The referrals UI also surfaces competitive program incentives via `ReferralsEmbedBounties`, allowing partners to view individual bounties, track completion periods, and inspect program details. Program FAQs render dynamic reward commission calculations using `constructRewardAmount` alongside embedded schemas.
Sources: [apps/web/app/ee/app.dub.co/embed/referrals/bounties/index.tsx:16-102](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/index.tsx#L16-L102), [apps/web/app/ee/app.dub.co/embed/referrals/faq.tsx:14-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx#L14-L26)
## Related
- [[Partner Portal and Onboarding]]
- [[OAuth2 Provider and API Tokens]]
---
## Technical docs: GET Get fraud alerts
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadminfraudalerts
## Parameters
## Responses
## Try It
---
## Technical docs: Bounties and Social Metrics
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/bounties-and-social-metrics
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/bounties/bountyId/social-content-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/bounties/%5BbountyId%5D/social-content-stats/route.ts)
- [apps/web/app/ee/api/bounties/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx)
- [apps/web/app/ee/api/embed/referrals/bounties/bountyId/social-content-stats/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/social-content-stats/route.ts)
- [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/bounties/bountyId/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/bounties/%5BbountyId%5D/page.tsx)
- [apps/web/lib/bounty/social-metrics-milestones.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts)
- [apps/web/ui/partners/bounties/bounty-social-content.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/bountyId/bounty-submission-details-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/%5BbountyId%5D/bounty-submission-details-sheet.tsx)
- [apps/web/lib/api/workflows/award-bounty/execute.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/award-bounty/execute.ts)
- [apps/web/ui/partners/bounties/use-social-metrics-milestones.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/use-social-metrics-milestones.ts)
- [apps/web/lib/bounty/api/approve-bounty-submission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts)
- [apps/web/app/ee/api/embed/referrals/bounties/bountyId/submissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/submissions/route.ts)
- [apps/web/app/ee/api/cron/bounties/queue-sync-social-metrics/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts)
- [apps/web/lib/zod/schemas/bounties.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/bounties.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/add-edit-bounty/bounty-criteria.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx)
- [apps/web/ui/partners/bounties/bounty-submission-details-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-submission-details-sheet.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/bounties/detail.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/detail.tsx)
- [apps/web/ui/partners/bounties/use-social-content.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/use-social-content.ts)
- [apps/web/ui/partners/bounties/bounty-submission-requirements.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-submission-requirements.tsx)
- [apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/bounties/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/index.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/bounties/submission-detail.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-detail.tsx)
- [apps/web/ui/partners/bounties/bounty-social-content-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content-preview.tsx)
- [apps/web/lib/bounty/api/get-social-metrics-updates.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/get-social-metrics-updates.ts)
- [apps/web/ui/partners/bounties/claim-bounty-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/claim-bounty-sheet.tsx)
- [apps/web/lib/bounty/api/create-bounty-submission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts)
- [apps/web/ui/partners/bounties/bounty-reward-criteria.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-reward-criteria.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/bounties/use-embed-social-content.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/use-embed-social-content.ts)
## Overview
Bounties and Social Metrics power partner reward campaigns by integrating performance tracking with social media engagement data. This subsystem enables program administrators to establish targeted promotional campaigns that dynamically evaluate partner-submitted content, such as posts across supported platforms, against specific viewership and interaction milestones. By automating background metric synchronization, validating submission criteria, and calculating incremental bonus caps, the system ensures accurate performance evaluation while reducing manual review overhead. Creators and partners can seamlessly track progress, submit campaign content via dashboard interfaces or embedded views, and receive automated payouts as milestones are achieved.
Sources: [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts:20-192](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts#L20-L192), [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:21-224](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L21-L224), [apps/web/lib/bounty/social-metrics-milestones.ts:33-126](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L33-L126), [apps/web/lib/bounty/api/create-bounty-submission.ts:48-562](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L48-L562)
## Bounty Configuration and Criteria Models
### Overview
Program bounties are configured using Zod validation schemas that define persistence structures, API payloads, and creator dashboard criteria models. The primary validation routines and data schemas govern how bounties are created, retrieved, and listed under workspace contexts, requiring specific plan capabilities such as business, advanced, or enterprise tiers.
Sources: [apps/web/app/ee/api/bounties/route.ts:27-31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/route.ts#L27-L31), [apps/web/app/ee/api/bounties/route.ts:163-166](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/route.ts#L163-L166), [apps/web/lib/zod/schemas/bounties.ts:241-261](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/bounties.ts#L241-L261)
### Bounty Schema Definitions
The bounty data model supports various configuration attributes, including start modes, submission frequencies, performance scopes, and reward calculations. The `BountySchema` definition outlines fields such as `id`, `name`, `type`, `startsAt`, `endsAt`, `startMode`, `maxSubmissions`, `rewardAmount`, and `submissionRequirements`. Related list schemas like `BountyListSchema` extend these definitions with aggregated submission counts.
Sources: [apps/web/lib/zod/schemas/bounties.ts:241-261](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/bounties.ts#L241-L261), [apps/web/lib/zod/schemas/bounties.ts:269-277](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/bounties.ts#L269-L277)
| Schema Field | Type / Enum | Default Value | Description |
| :--- | :--- | :--- | :--- |
| `id` | `string` | *None* | Unique identifier for the bounty. |
| `name` | `string \| null` | `null` | Display name of the bounty. |
| `type` | `BountyType` | *None* | Classification type of the bounty. |
| `startMode` | `BountyStartMode` | *None* | Execution mode governing how the bounty period begins. |
| `maxSubmissions` | `number` | *None* | Maximum allowable submissions for the bounty. |
| `rewardAmount` | `number \| null` | `null` | Fixed monetary reward amount. |
| `performanceCondition` | `awardBountyConditionSchema \| null` | `null` | Evaluated conditions required to trigger the reward. |
| `submissionRequirements` | `submissionRequirementsSchema \| null` | `null` | Criteria requirements including manual inputs or social metrics. |
Sources: [apps/web/lib/zod/schemas/bounties.ts:241-261](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/bounties.ts#L241-L261)
### Social Criteria Rules and Dashboard Configuration
Within the creator dashboard, the `BountyCriteria` component renders distinct configuration views depending on the active bounty type interface (`performance`, `submission`, or `socialMetrics`). The `BountyCriteriaSocialMetrics` component evaluates social platform constraints by inspecting criteria rules. It tracks whether a target platform, minimum metric count, and specific metric type are configured.
```typescript
export function BountyCriteriaSocialMetrics() {
const { watch, setValue } = useBountyFormContext();
const [submissionRequirements, rewardAmount] = watch([
"submissionRequirements",
"rewardAmount",
]);
const socialMetrics = submissionRequirements?.socialMetrics;
const hasChannel = socialMetrics?.platform != null;
const hasMinCount =
socialMetrics?.minCount != null && socialMetrics.minCount > 0;
const hasMetric = socialMetrics?.metric != null;
// ...
}
```
If any required field is missing, inline popover validation elements flag the configuration as invalid using distinct visual states.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx:41-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx#L41-L55), [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx:91-101](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/add-edit-bounty/bounty-criteria-social-metrics.tsx#L91-L101), [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/add-edit-bounty/bounty-criteria.tsx:18-32](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx#L18-L32)
## Social Content Inspection and Embeds
### Overview
Partner interfaces inspect social media content by parsing post URLs, validating platform identifiers, and fetching engagement statistics. The application provides two distinct API endpoints for retrieving social content statistics: one for authenticated partner profiles (`/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats`) and another for referral embed tokens (`/api/embed/referrals/bounties/[bountyId]/social-content-stats`). Both routes validate search parameters via Zod (`searchParamsSchema` with `z.httpUrl`), assert rate limits using `RATELIMIT_POLICIES.socialContentStats`, verify bounty requirements through `resolveBountyDetails`, and invoke `getSocialContent` to query platform metrics.
Sources: [apps/web/app/ee/api/partner-profile/programs/programId/bounties/bountyId/social-content-stats/route.ts:16-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/bounties/%5BbountyId%5D/social-content-stats/route.ts#L16-L91), [apps/web/app/ee/api/embed/referrals/bounties/bountyId/social-content-stats/route.ts:16-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/social-content-stats/route.ts#L16-L80)
> [!NOTE]
> Rate limiting for social content inspection is strictly enforced per partner identifier using predefined Upstash rate-limit policies to prevent abuse of platform scraping routines.
> Sources: [apps/web/app/ee/api/partner-profile/programs/programId/bounties/bountyId/social-content-stats/route.ts:27-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/bounties/%5BbountyId%5D/social-content-stats/route.ts#L27-L30), [apps/web/app/ee/api/embed/referrals/bounties/bountyId/social-content-stats/route.ts:26-29](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/social-content-stats/route.ts#L26-L29)
### Client Hooks and Requirement Evaluation
Frontend components interact with these endpoints through dedicated hooks: `useSocialContent` for standard partner profile dashboards and `useEmbedSocialContent` for embedded referral views. These hooks format search parameters and leverage SWR with disabled focus revalidation to query content statistics dynamically.
Sources: [apps/web/ui/partners/bounties/use-social-content.ts:11-32](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/use-social-content.ts#L11-L32), [apps/web/app/ee/app.dub.co/embed/referrals/bounties/use-embed-social-content.ts:13-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/use-embed-social-content.ts#L13-L47)
Once content metadata is retrieved, `evaluateSocialContentRequirements` evaluates whether a post complies with campaign rules by checking two primary conditions:
- `isPostedFromYourAccount`: Validates that the partner platform identifier matches the fetched content handle case-insensitively, and that the platform is verified.
- `isAfterStartDate`: Confirms that the content's `publishedAt` timestamp is not before the bounty's `startsAt` date.
Sources: [apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts:8-32](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/evaluate-social-content-requirements.ts#L8-L32)
### Platform URL Validation and Embedding Previews
The `BountySocialContentPreview` component renders native iframe embeds across supported social platforms by parsing submission URLs and transforming them into platform-specific embed URLs and aspect ratios via helper functions.
Sources: [apps/web/ui/partners/bounties/bounty-social-content-preview.tsx:27-153](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content-preview.tsx#L27-L153)
| Social Platform | Target Hostnames | Embed URL Generation Pattern | Aspect Ratio |
| :--- | :--- | :--- | :--- |
| `youtube` | `youtu.be`, `youtube.com`, `m.youtube.com` | `https://www.youtube.com/embed/{id}` (supports standard watch URLs and shorts) | `aspect-video` or `aspect-[9/16]` |
| `instagram` | `instagram.com`, `m.instagram.com` | `https://www.instagram.com/p/{code}/embed/` or `/reel/{code}/embed/` | `aspect-square` or `aspect-[9/16]` |
| `tiktok` | `tiktok.com`, `m.tiktok.com`, `vm.tiktok.com` | `https://www.tiktok.com/embed/v2/{videoId}` | `aspect-[9/16]` |
| `twitter` | `twitter.com`, `x.com` | `https://platform.twitter.com/embed/Tweet.html?id={tweetId}` | `aspect-square` |
| `linkedin` | `linkedin.com` | `https://www.linkedin.com/embed/feed/update/urn:li:activity:{activityId}` | `aspect-video` |
Sources: [apps/web/ui/partners/bounties/bounty-social-content-preview.tsx:35-147](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content-preview.tsx#L35-L147)
> [!CAUTION]
> If a submission URL does not match the expected platform hostnames or lacks required path identifiers like video IDs or shortcodes, `getSocialContentEmbedUrl` returns `null`, causing the preview component to render nothing.
> Sources: [apps/web/ui/partners/bounties/bounty-social-content-preview.tsx:31-118](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content-preview.tsx#L31-L118)
## Partner Submission and Validation Flow
### Overview
The partner bounty submission flow governs how creators submit evidence of completion for manual and social bounties through interactive claim sheets and API endpoints. The submission lifecycle is managed by the `BountySubmissionHandler` class, which handles requests sent to the embed route and executes sequential validation, persistence, and notification routines.
Sources: [apps/web/app/ee/api/embed/referrals/bounties/bountyId/submissions/route.ts:9-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/bounties/%5BbountyId%5D/submissions/route.ts#L9-L39), [apps/web/lib/bounty/api/create-bounty-submission.ts:48-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L48-L111)
The submission execution pipeline flows sequentially through several distinct phases:
`fetchBountyAndEnrollment()` → `resolvePeriodNumber()` → `validateEligibility()` → `validateRequirements()` → `validateFiles()` → `validateSocialContent()` → `mergeSubmissionData()` → `persist()` → `sendNotifications()`
Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:91-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L91-L111)
### Enrollment and Eligibility Checks
Before persisting any entry, `BountySubmissionHandler` checks partner enrollment state and campaign parameters. Performance bounties are blocked at the API level since they track automatically rather than via partner submissions. Furthermore, social metrics bounties reject draft saves entirely, requiring direct final submissions.
Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:245-310](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L245-L310)
> [!WARNING]
> If a partner attempts to save a draft for a bounty that has social metrics enabled, `validateEligibility()` throws a bad request error preventing draft persistence.
> Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:303-310](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L303-L310)
### URL Domain Enforcement and File Security
When requirements specify URL submissions, the handler enforces strict domain filtering and file storage boundaries to prevent malicious payloads.
Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:367-435](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L367-L435)
- **Domain Filtering**: `validateUrlDomains` strips `www` prefixes and ensures submitted URLs match or are subdomains of the allowed domains list configured on the bounty requirements.
- **File Validation**: `validateFiles` parses uploaded file URLs against the Cloudflare R2 storage origin and verifies that pathnames strictly start with the expected prefix `/programs/{programId}/bounties/{bountyId}/submissions/{partnerId}/`.
Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:368-435](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L368-L435)
| Validation Phase | Target Field / Input | Enforcement Rule | Error Code |
| :--- | :--- | :--- | :--- |
| Eligibility | `bounty.type` | Must not be `performance` | `forbidden` |
| Period Check | `periodNumber` | Must fall within active campaign window and `maxSubmissions` | `bad_request` |
| Image Requirement | `files` | Must include at least one file if `submissionRequirements.image` is set | `unprocessable_entity` |
| URL Requirement | `urls` | Must include at least one URL if `submissionRequirements.url` is set | `unprocessable_entity` |
| Domain Enforcement | `urls` | Host must match `submissionRequirements.url.domains` whitelist | `unprocessable_entity` |
| File Storage | `files[].url` | Origin must match `R2_URL` and pathname must start with expected partner submission path | `unprocessable_entity` |
Sources: [apps/web/lib/bounty/api/create-bounty-submission.ts:245-435](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/create-bounty-submission.ts#L245-L435)
### Handling Claim Forms and State Management
The frontend claim sheet component (`ClaimBountySheetContent`) synchronizes form state using React Hook Form and manages asynchronous submission actions via `useAction`. It evaluates real-time requirements such as verifying connected social accounts and confirming post dates using `SocialContentUrlField` and `SocialContentRequirementChecks`.
Sources: [apps/web/ui/partners/bounties/bounty-social-content.tsx:16-186](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-social-content.tsx#L16-L186), [apps/web/ui/partners/bounties/claim-bounty-sheet.tsx:364-581](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/claim-bounty-sheet.tsx#L364-L581)
> [!NOTE]
> The claim sheet disables submission controls whenever file uploads are active, social content is currently verifying, or social requirements remain unmet.
> Sources: [apps/web/ui/partners/bounties/claim-bounty-sheet.tsx:573-580](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/claim-bounty-sheet.tsx#L573-L580)
## Metric Synchronization and Cron Jobs
### Overview
The metric synchronization and cron subsystem keeps campaign engagement counts updated across active submissions. It operates via individual synchronization endpoints and scheduled background cron jobs that fetch platform metrics, evaluate earning caps, update submission records, and notify partners upon milestone completion.
Sources: [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts:1-224](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts#L1-L224), [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:1-258](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L1-L258)
### Synchronization Call-Chain Execution
When a synchronization request is processed for a specific submission, the API traverses a strict evaluation and validation chain.
```mermaid
sequenceDiagram
autonumber
participant POST as POST Route
participant HRSEC as hasReachedSocialMetricsEarningCap
participant GSMEC as getSocialMetricsEarningCap
participant GSMM as getSocialMetricsMilestones
POST->>HRSEC: { bounty, submission }
HRSEC->>GSMEC: getSocialMetricsEarningCap(bounty)
GSMEC->>GSMM: getSocialMetricsMilestones(bounty)
GSMM-->>GSMEC: milestone list
GSMEC-->>HRSEC: earningCap threshold
HRSEC-->>POST: boolean result
```
1. `POST` receives the incoming request to sync social metrics and extracts the target submission.
Sources: [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts:30-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts#L30-L36)
2. `hasReachedSocialMetricsEarningCap` checks if the submission's current metric count meets or exceeds the campaign cap.
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:95-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L95-L109)
3. `getSocialMetricsEarningCap` retrieves the highest valid threshold from the milestone list.
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:84-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L84-L92)
4. `getSocialMetricsMilestones` computes all payable milestones including base rewards and incremental bonus tiers in ascending order.
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:33-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L33-L81)
> [!TIP]
> During bounty-wide synchronization where no `submissionId` is supplied, the `POST` route offloads processing entirely to QStash by publishing a background job to `/api/cron/bounties/sync-social-metrics`.
> Sources: [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts:87-95](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts#L87-L95)
### Cron Job Queueing and Batching
Scheduled cron routes automate the periodic sweep of active bounties and submissions. The queueing endpoint (`GET`) identifies active submission bounties containing social metrics requirements and chunks them into batches of 100 before dispatching them to QStash.
Sources: [apps/web/app/ee/api/cron/bounties/queue-sync-social-metrics/route.ts:11-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts#L11-L45)
The worker cron route (`POST`) processes submissions in increments defined by `SUBMISSION_BATCH_SIZE`. It queries up to 50 non-approved and non-rejected submissions ordered ascending by identifier, applies cursor pagination via `startingAfter`, and queues subsequent batches automatically if the batch limit is reached.
Sources: [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:29-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L29-L120), [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:231-246](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L231-L246)
| Cron Route Path | HTTP Method | Batch Size / Limit | Trigger / Queue Name | Primary Action |
| :--- | :--- | :--- | :--- | :--- |
| `/api/cron/bounties/queue-sync-social-metrics` | `GET` | 100 bounties per chunk | `sync-bounty-social-metrics` | Queries active social metrics bounties and enqueues batch background jobs |
| `/api/cron/bounties/sync-social-metrics` | `POST` | 50 submissions per batch (`SUBMISSION_BATCH_SIZE`) | QStash publishing (`startingAfter` cursor) | Fetches social metrics updates, updates submission records in transactions, and sends batch completion emails |
Sources: [apps/web/app/ee/api/cron/bounties/queue-sync-social-metrics/route.ts:13-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts#L13-L44), [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:29-255](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L29-L255)
### Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Cursor pagination with fixed batch sizes (`SUBMISSION_BATCH_SIZE = 50`)** | Prevents memory exhaustion and database query timeouts on bounties with thousands of partner submissions. | Requires chained asynchronous QStash job triggers to complete synchronization across all pages. |
| **Asynchronous background queuing via QStash** | Offloads heavy multi-submission scraping and database transactions away from client-facing API response cycles. | Introduces eventual consistency in social metric counters visible to partners and creators. |
| **`Promise.allSettled` for social content fetching** | Ensures individual scraping failures or platform timeouts do not abort synchronization for other partner submissions. | Requires robust post-processing filters to validate settled fulfillment states and integer metric types. |
Sources: [apps/web/app/ee/api/bounties/bountyId/sync-social-metrics/route.ts:87-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/bounties/%5BbountyId%5D/sync-social-metrics/route.ts#L87-L104), [apps/web/app/ee/api/cron/bounties/sync-social-metrics/route.ts:113-200](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts#L113-L200), [apps/web/lib/bounty/api/get-social-metrics-updates.ts:51-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/get-social-metrics-updates.ts#L51-L92)
## Milestone Evaluation and Cap Calculation
### Overview
The milestone evaluation and cap calculation engine processes bounty details to construct performance tiers, calculate earning limits, determine pending rewards, and format commission strings. Functions such as `getSocialMetricsMilestones` resolve bounty particulars using `resolveBountyDetails`, extracting `minCount` and `incrementalBonus` properties. It initializes a base tier milestone starting at threshold `0` up to `minCount` with the full `rewardAmount`. When a valid incremental bonus structure exists containing `incrementCount`, `bonusPerIncrement`, and `maxCount`, a `for` loop iterates from `minCount + incrementCount` up to `maxCount` in steps of `incrementCount`, pushing subsequent milestones with a `fromThreshold` of `t - incrementCount` and `rewardAmount` set to `bonusPerIncrement`.
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:33-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L33-L81)
### Milestone and Cap Functions
The milestone engine provides utility functions to query earning limits, sync states, and commission descriptions.
| Function Name | Parameters | Return Type | Description |
| :--- | :--- | :--- | :--- |
| `getSocialMetricsMilestones` | `bounty: BountyInfoInput \| undefined \| null` | `SocialMetricsMilestone[]` | Assembles all payable milestones (base reward + bonus increments) in ascending order. |
| `getSocialMetricsEarningCap` | `bounty: BountyInfoInput \| undefined \| null` | `number \| null` | Returns the highest metric count earning a reward (the last milestone threshold), or `null`. |
| `hasReachedSocialMetricsEarningCap` | `{ bounty, submission }` | `boolean` | Checks if a submission's live metric count meets or exceeds the earning cap. |
| `getPendingSocialMetricsMilestones` | `{ bounty, submission }` | `SocialMetricsMilestone[]` | Filters milestones where threshold is less than or equal to `socialMetricCount` and greater than `approvedSocialMetricThreshold`. |
| `isSocialMetricsMilestoneApproved` | `{ milestone, submission }` | `boolean` | Verifies if a milestone has been approved or paid based on thresholds or legacy status. |
| `buildMilestonesCommissionDescription` | `{ bountyName, metric, milestone }` | `string` | Formats a commission description string using `nFormatter`. |
| `groupSocialMetricsMilestones` | `milestones: SocialMetricsMilestone[]` | Grouped milestone array | Merges consecutive milestones sharing identical rewards into ranges while keeping the base tier isolated. |
| `getSocialMetricsMilestoneStatus` | `{ milestone, submission }` | `SocialMetricsMilestoneStatus` | Resolves the display status (`"approved"`, `"pending"`, `"inProgress"`, `"rejected"`). |
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:33-221](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L33-L221)
### Call-Chain Execution Walkthrough
Milestone state computation follows a strict execution flow from raw inputs to UI state properties via `useSocialMetricsMilestones`:
1. `useSocialMetricsMilestones()` extracts the social metric name using `resolveBountyDetails(bounty)?.socialMetrics?.metric`.
2. `getSocialMetricsMilestones()` evaluates the base tier and loops through `incrementalBonus` tiers if `incrementCount > 0`.
3. `getPendingSocialMetricsMilestones()` filters the resulting milestones where `threshold <= socialMetricCount && threshold > approvedSocialMetricThreshold`.
4. `getSocialMetricsEarningCap()` retrieves the final threshold from the array as the campaign earning ceiling.
5. `hasReachedSocialMetricsEarningCap()` checks if `submission.socialMetricCount >= earningCap` to halt further sync tasks.
Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:33-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L33-L109), [apps/web/ui/partners/bounties/use-social-metrics-milestones.ts:11-58](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/use-social-metrics-milestones.ts#L11-L58)
> [!NOTE]
> Legacy approved submissions containing a `null` approved threshold treat every reached milestone as paid by evaluating `submission.status === "approved" && milestone.threshold <= (submission.socialMetricCount ?? 0)`.
> Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:128-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L128-L144)
> [!TIP]
> The `groupSocialMetricsMilestones` function leaves the base milestone at `fromThreshold: 0` on its own while grouping subsequent sequential tiers that share identical reward amounts and contiguous thresholds.
> Sources: [apps/web/lib/bounty/social-metrics-milestones.ts:167-198](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/social-metrics-milestones.ts#L167-L198)
## Submission Review and Reward Payouts
### Overview
The submission review and reward payout lifecycle transitions partner entries from pending administrative evaluation to approved commissions. Reviewers inspect claims, media attachments, and live social metrics using dedicated administration sheets or automated workflows. Upon approval, system routines queue partner commissions, write comprehensive audit logs, and dispatch notifications via email templates.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/bountyId/bounty-submission-details-sheet.tsx:421-520](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/%5BbountyId%5D/bounty-submission-details-sheet.tsx#L421-L520), [apps/web/lib/api/workflows/award-bounty/execute.ts:212-254](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/award-bounty/execute.ts#L212-L254), [apps/web/lib/bounty/api/approve-bounty-submission.ts:184-371](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L184-L371)
### Administrative Review Interface
Reviewers inspect individual submissions through `BountySubmissionDetailsSheet`, which surfaces live metric progress, attached files, uploaded URLs, and rejection metadata. When a campaign incorporates social metrics, reviewers can trigger manual sync actions via `refreshSubmissionSocialMetrics` or review pending engagement milestones.
```typescript
export function SocialContentPreview({
bounty,
submission,
}: {
bounty: PartnerBountyProps;
submission: PartnerBountySubmission;
}) {
const bountyInfo = resolveBountyDetails(bounty);
const { socialMetrics, socialPlatform } = bountyInfo ?? {};
const url = submission.urls?.[0] ?? "";
if (!socialMetrics || !socialPlatform || !url) {
return null;
}
const socialMetricCount = submission.socialMetricCount ?? 0;
const minCount = socialMetrics.minCount ?? 0;
const percent =
minCount > 0 ? Math.min((socialMetricCount / minCount) * 100, 100) : 100;
const isComplete = percent >= 100;
const PlatformIcon = PLATFORM_ICONS[socialPlatform.value];
const lastSyncedAt = submission.socialMetricsLastSyncedAt;
return (
{/* Renders progress bar, platform icon, and social preview component */}
);
}
```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/bounties/bountyId/bounty-submission-details-sheet.tsx:421-488](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/bounties/%5BbountyId%5D/bounty-submission-details-sheet.tsx#L421-L488), [apps/web/ui/partners/bounties/bounty-submission-details-sheet.tsx:47-118](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/bounties/bounty-submission-details-sheet.tsx#L47-L118)
### Approval Workflows and Side Effects
The approval engine processes standard submissions and social milestone tiers through targeted execution functions. When approving social metrics via `approveSocialMetricsMilestones`, the system validates pending milestones, calculates incremental reward amounts, updates database states using Prisma transactions, queues commissions, and executes side-effects asynchronously.
```typescript
async function approveSocialMetricsMilestones({
submissionId,
submission,
metric,
user,
}: {
submissionId: string;
submission: SubmissionToApprove;
metric: string;
user: Session["user"];
}) {
const { bounty } = submission;
const pendingMilestones = getPendingSocialMetricsMilestones({
bounty,
submission,
});
const earningCap = getSocialMetricsEarningCap(bounty);
if (pendingMilestones.length === 0 || earningCap == null) {
throw new DubApiError({
code: "bad_request",
message:
"The partner hasn't reached a new milestone for this bounty yet, so there is nothing to approve.",
});
}
const firstPendingMilestone = pendingMilestones[0];
const approvedThreshold =
pendingMilestones[pendingMilestones.length - 1].threshold;
const completesEarningCap = approvedThreshold >= earningCap;
const approvedSubmission = await prisma.bountySubmission.update({
where: {
id: submissionId,
approvedSocialMetricThreshold: submission.approvedSocialMetricThreshold,
status: {
notIn: [
BountySubmissionStatus.approved,
BountySubmissionStatus.draft,
],
},
},
data: {
approvedSocialMetricThreshold: approvedThreshold,
status: completesEarningCap ? "approved" : "submitted",
reviewedAt: new Date(),
userId: user.id,
rejectionNote: null,
rejectionReason: null,
},
include: submissionApprovalInclude,
});
const rewardAmount = pendingMilestones.reduce(
(sum, { rewardAmount }) => sum + rewardAmount,
0,
);
const description = buildMilestonesCommissionDescription({
bountyName: bounty.name,
metric,
milestone: {
fromThreshold: firstPendingMilestone.fromThreshold,
threshold: approvedThreshold,
},
});
await queuePartnerCommissionCreation({
event: "custom",
partnerId: submission.partnerId,
programId: submission.programId,
amount: rewardAmount,
quantity: 1,
userId: user.id,
source: CommissionSource.user,
description,
bountySubmissionId: submissionId,
metadata: {
socialMetrics: {
metric,
fromThreshold: firstPendingMilestone.fromThreshold,
threshold: approvedThreshold,
milestones: pendingMilestones,
},
},
});
runApprovalSideEffects({
approvedSubmission,
bounty,
user,
description: completesEarningCap
? `Bounty submission approved for ${approvedSubmission.partner.id}`
: `Bounty milestones approved up to ${nFormatter(approvedThreshold, { full: true })} ${metric} for ${approvedSubmission.partner.id}`,
notifyPartner: completesEarningCap,
});
return BountySubmissionSchema.parse(approvedSubmission);
}
```
Sources: [apps/web/lib/bounty/api/approve-bounty-submission.ts:204-316](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L204-L316)
> [!WARNING]
> Database updates for social metric milestones explicitly guard against race conditions using P2025 error catching on threshold matches, throwing a `bad_request` API error if the submission has already been reviewed or processed concurrently.
> Sources: [apps/web/lib/bounty/api/approve-bounty-submission.ts:237-270](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L237-L270)
### Call-Chain Execution Walkthrough
The reward approval sequence follows a deterministic flow from administrative trigger to audit logging and partner email dispatch:
1. `approveSocialMetricsMilestones()` or standard approval handlers evaluate incoming submission payloads against active Prisma records.
2. `getPendingSocialMetricsMilestones()` identifies unapproved performance tiers or social engagement thresholds.
3. `prisma.bountySubmission.update()` writes the new `approvedSocialMetricThreshold` and adjusts the submission status (`submitted` or `approved`).
4. `queuePartnerCommissionCreation()` logs the commission entry with source `CommissionSource.user` and assigns the associated `bountySubmissionId`.
5. `runApprovalSideEffects()` uses `waitUntil()` to wrap concurrent execution of `recordAuditLog()` and optional partner notification emails via `sendEmail()`.
Sources: [apps/web/lib/bounty/api/approve-bounty-submission.ts:184-371](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L184-L371)
> [!TIP]
> The `runApprovalSideEffects` function delegates tasks to `waitUntil()`, ensuring audit logs and notification emails resolve asynchronously without delaying the synchronous API response returned to the client.
> Sources: [apps/web/lib/bounty/api/approve-bounty-submission.ts:318-370](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L318-L370)
### Terminal Submission States and Notifications
Performance-based bounties executed via automated workflows check terminal status reasons and partner eligibility before transitioning submission rows. Once conditions are satisfied, workflow actions transition the submission status to `submitted`, record the completion timestamp, and dispatch notification emails to both the partner and program owners.
| Terminal Status | Action Type | Reason Mapping | Notification Template |
| :--- | :--- | :--- | :--- |
| `submitted` | Workflow Execution | `"finished"` | `BountyCompleted`, `NewBountySubmission` |
| `approved` | Manual / Milestone Approval | `"been awarded"` | `BountyApproved` |
| `rejected` | Administrative Rejection | `"been rejected"` | None (Rejection Note/Reason recorded) |
Sources: [apps/web/lib/api/workflows/award-bounty/execute.ts:23-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/award-bounty/execute.ts#L23-L30), [apps/web/lib/api/workflows/award-bounty/execute.ts:212-254](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/award-bounty/execute.ts#L212-L254), [apps/web/lib/bounty/api/approve-bounty-submission.ts:318-371](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/bounty/api/approve-bounty-submission.ts#L318-L371)
## Related
- [[Partner Program Management]]
- [[Commission Rules and Rewards]]
---
## Technical docs: Fraud Detection and Hold Rules
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/fraud-detection-and-hold-rules
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts)
- [apps/web/scripts/migrations/backfill-hold-pending-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-pending-commissions.ts)
- [apps/web/lib/api/fraud/release-hold-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts)
- [apps/web/lib/api/fraud/hold-processed-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts)
- [apps/web/app/ee/api/workflows/create-partner-commission/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts)
- [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts)
- [apps/web/lib/api/fraud/hold-pending-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts)
- [apps/web/app/ee/api/cron/fraud/release-all-hold-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/fraud/release-all-hold-commissions/route.ts)
- [apps/web/lib/api/fraud/release-all-hold-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-all-hold-commissions.ts)
- [apps/web/app/ee/api/cron/fraud/release-hold-commissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/fraud/release-hold-commissions/route.ts)
- [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts)
- [apps/web/lib/partner-referrals/create-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/risk-events-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/risk-events-table.tsx)
- [apps/web/lib/partner-referrals/create-network-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts)
- [apps/web/app/ee/api/fraud/rules/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts)
- [apps/web/app/ee/api/cron/commissions/referrals/queue/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/queue/route.ts)
- [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx)
- [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx)
- [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/ee/admin.dub.co/dashboard/commissions/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx)
- [apps/web/lib/api/fraud/report-network-level-ban.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts)
- [apps/web/lib/api/fraud/resolve-fraud-groups.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/resolve-fraud-groups.ts)
- [apps/web/scripts/misc/cleanup-generic-email-fraud-events.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/misc/cleanup-generic-email-fraud-events.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts)
- [apps/web/scripts/customers/beehiiv/fraud-checks.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fraud-checks.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/layout.tsx)
## Overview
Fraud Detection and Hold Rules provide a comprehensive risk-mitigation framework designed to safeguard partner programs against suspicious activity, duplicate accounts, and fraudulent referral traffic. By integrating real-time fraud monitoring directly into partner commission workflows, the system automatically detects policy violations—such as duplicate identities, matching customer emails, or network-level bans—and intercepts commission creation to place vulnerable earnings on hold. This proactive mechanism prevents premature payouts, recalculates balances, and manages automated hold releases or expiration schedules through orchestrated cron jobs, ensuring robust financial integrity across workspaces.
Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:19-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L19-L34)
## Configurable Fraud Rules and Program Controls
### Overview
Public and workspace fraud rule configuration and validation manage how individual partner programs enforce detection mechanisms. Workspace-level settings allow authorized users to query existing rules or update rule configurations and active statuses via dedicated API endpoints. When fetching rules via `GET /api/fraud/rules`, the system retrieves stored configurations from the database and merges them with default overrides—such as platform settings for paid traffic detection—ensuring every rule has a valid initial state.
Sources: [apps/web/app/ee/api/fraud/rules/route.ts:31-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L31-L69)
```mermaid
sequenceDiagram
participant Client
participant API as PATCH /api/fraud/rules
participant DB as Prisma DB
participant Resolve as resolveFraudGroups
Client->>API: Send update payload
API->>API: Parse via updateFraudRuleSettingsSchema
loop For each rule
API->>DB: Upsert fraud rule (config & disabledAt)
end
alt Rule disabled with resolvePendingEvents=true
API->>Resolve: Background execution via waitUntil()
end
API->>Client: Return { success: true }
```
Sources: [apps/web/app/ee/api/fraud/rules/route.ts:75-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L75-L144)
### Rule Modification and Automatic Resolution
The `PATCH /api/fraud/rules` endpoint processes settings updates validated by `updateFraudRuleSettingsSchema`. Rules are iterated, and their configurations are persisted via database upserts, mapping enabled states to `disabledAt` timestamps. If a rule is disabled with `resolvePendingEvents` set to true, a background task executes `resolveFraudGroups` via Vercel's `waitUntil()` utility, automatically releasing held commissions with the resolution reason `"Resolved automatically because the fraud rule was disabled."`.
Sources: [apps/web/app/ee/api/fraud/rules/route.ts:75-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L75-L141)
> [!WARNING]
> Disabling a fraud rule with `resolvePendingEvents: true` immediately triggers automatic group resolution and commission release in the background, which cannot be rolled back directly via the PATCH response.
Sources: [apps/web/app/ee/api/fraud/rules/route.ts:116-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/fraud/rules/route.ts#L116-L141)
### Referral Source Banning Rules
The `checkReferralSourceBanned` rule evaluates click event contexts against a list of banned domains. It parses raw configuration using a Zod schema requiring an optional string array of domains, defaulting to an empty list.
Sources: [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts:7-13](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts#L7-L13)
```typescript
export const checkReferralSourceBanned = defineFraudRule({
type: "referralSourceBanned",
evaluate: async ({ click }: FraudEventContext, rawConfig) => {
const parsedConfig = configSchema.safeParse(rawConfig ?? defaultConfig);
if (!parsedConfig.success) {
console.error(
`[checkReferralSourceBanned] Invalid config:`,
parsedConfig.error,
);
return {
triggered: false,
};
}
const config = parsedConfig.data;
// Normalize banned domains by extracting domains and removing www. prefix
const normalizedBannedDomains = config.domains
.map((domain) => getDomainWithoutWWW(domain))
.filter((domain): domain is string => Boolean(domain));
if (normalizedBannedDomains.length === 0 || !click) {
return {
triggered: false,
};
}
// Return early if both referer and referer_url are null/empty
if (!click.referer && !click.referer_url) {
return {
triggered: false,
};
}
// Check both referer and referer_url against banned sources
// Normalize referrers by extracting domains and removing www. prefix
const referrerCandidates = [click.referer, click.referer_url]
.filter((value): value is string => Boolean(value))
.map((referrer) => getDomainWithoutWWW(referrer))
.filter((domain): domain is string => Boolean(domain));
for (const referrer of referrerCandidates) {
for (const source of normalizedBannedDomains) {
if (minimatch(referrer, source, { nocase: true })) {
return {
triggered: true,
metadata: {
source,
},
};
}
}
}
return {
triggered: false,
};
},
});
```
Sources: [apps/web/lib/api/fraud/rules/check-referral-source-banned.ts:15-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/rules/check-referral-source-banned.ts#L15-L75)
## Identity and Payout Fraud Detection
### Overview
Identity and payout fraud detection mechanisms identify colluding partners by cross-referencing shared verification sessions, payment method hashes, crypto wallet addresses, and network-level bans. When overlapping identifiers or bans are detected, the system generates fraud events across active program enrollments and initiates commission holds.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:15-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L15-L16)
### Duplicate Identity Verification
The `detectDuplicateIdentityFraud` function accepts a Veriff session identifier and risk labels. It extracts session IDs associated with valid risk labels, appends the current session ID, deduplicates the array, and queries database program enrollments where the associated partner matches any collected Veriff session ID.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L67)
Enrollments are filtered to ensure the `partnerDuplicateAccount` fraud rule is enabled in the program settings. Partners are grouped by `programId`, and groups containing fewer than two partners are filtered out. For every eligible partner within a multi-partner group, fraud events of type `partnerDuplicateAccount` are created and dispatched to `createFraudEvents`. Finally, affected pending and processed commissions are placed on hold via settlement promises.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:74-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L74-L144)
> [!NOTE]
> Partners whose enrollment status is included in `INACTIVE_ENROLLMENT_STATUSES` or who have `riskMonitoringDisabledAt` populated are skipped during event generation, even if they share a Veriff session ID with active partners.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:114-119](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L114-L119)
### Payment Method Collisions
The `detectDuplicatePayoutMethodFraud` function evaluates either a `payoutMethodHash` or a `cryptoWalletAddress` using mutually exclusive options. It searches for program enrollments linked to partners sharing the specified payout hash or wallet address.
Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:10-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L10-L44)
```typescript
type DetectDuplicatePayoutMethodFraudOptions =
| { payoutMethodHash: string; cryptoWalletAddress?: never }
| { cryptoWalletAddress: string; payoutMethodHash?: never };
export async function detectDuplicatePayoutMethodFraud({
payoutMethodHash,
cryptoWalletAddress,
}: DetectDuplicatePayoutMethodFraudOptions) {
if (!payoutMethodHash && !cryptoWalletAddress) {
return;
}
let programEnrollments = await prisma.programEnrollment.findMany({
where: {
partner: {
OR: [
...(payoutMethodHash ? [{ payoutMethodHash }] : []),
...(cryptoWalletAddress ? [{ cryptoWalletAddress }] : []),
],
},
},
select: {
programId: true,
partnerId: true,
status: true,
riskMonitoringDisabledAt: true,
program: {
select: {
fraudRules: true,
},
},
},
});
...
```
Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:10-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L10-L48)
After fetching matching enrollments, the function validates that `partnerDuplicateAccount` is enabled, groups partners by program, excludes single-partner groups, and records fraud events containing either `payoutMethodHash` or `cryptoWalletAddress` in their metadata.
Sources: [apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts:50-109](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-payout-method-fraud.ts#L50-L109)
### Network-Level Bans
The `reportNetworkLevelBan` function alerts other programs when a partner is banned within a specific program. It queries all active program enrollments for the partner excluding the issuing program ID and ensuring risk monitoring is active.
Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:12-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L12-L43)
```typescript
export async function reportNetworkLevelBan({
partnerId,
programId,
bannedReason,
bannedAt,
}: {
partnerId: string;
programId: string;
bannedReason: PartnerBannedReason | null;
bannedAt: Date | null;
}) {
let affectedProgramEnrollments = await prisma.programEnrollment.findMany({
where: {
partnerId,
programId: {
not: programId,
},
status: {
notIn: INACTIVE_ENROLLMENT_STATUSES,
},
riskMonitoringDisabledAt: null,
},
select: {
programId: true,
partnerId: true,
program: {
select: {
fraudRules: true,
},
},
},
});
...
```
Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:12-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L12-L43)
Enrollments are filtered against the `partnerCrossProgramBan` fraud rule. Eligible enrollments trigger `partnerCrossProgramBan` fraud events with `sourceProgramId` set to the banning program and metadata containing `bannedReason` and `bannedAt`. Pending and processed commissions are then held for all affected groups.
Sources: [apps/web/lib/api/fraud/report-network-level-ban.ts:52-91](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/report-network-level-ban.ts#L52-L91)
## Commission Creation and Fraud Interception
### Overview
Partner commission creation workflows evaluate fraud risks inline to determine whether newly recorded commissions must be placed on hold immediately upon creation. The interception mechanism operates during side-effect execution following commission creation, inspecting both real-time customer risk events and partner-level pending fraud groups.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:534-651](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L534-L651)
### Fraud Evaluation and Interception Walkthrough
When a commission is created, the workflow executes `stepRunSideEffects()`, which inspects workspace plan capabilities and verifies if risk monitoring applies to the transaction. The call chain proceeds through evaluation and status modification functions:
`stepRunSideEffects()` → evaluates `shouldRunRiskMonitoring` (checking for customer, event ID, and click event) → `detectAndRecordFraudEvent()` → checks `canManageFraudEvents` from workspace plan capabilities → `prisma.fraudEventGroup.findFirst()` (evaluating partner-level scope) → `prisma.commission.update()` (transitioning status to `hold` if flagged).
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:602-677](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L602-L677)
If risk rules trigger or pending risk groups exist, and the commission is not a clawback (`commission.earnings > 0`) with an explicit status input, the commission record transitions from `pending` to `hold`.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:630-675](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L630-L675)
> [!WARNING]
> Clawback commissions (where earnings are less than or equal to zero) are never held, even if risk monitoring rules or pending partner-level fraud groups are triggered.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:601-657](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L601-L657)
### Network Referral Commission Interception and Duplicate Handling
For network-level referrals, `createNetworkReferralCommission()` validates referrer program enrollment, active duration limits, and payout fee earnings before creating a referral commission record.
Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:33-242](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L33-L242)
```typescript
try {
commission = await prisma.commission.create({
data: commissionData,
});
console.log("Network referral commission created", commission);
} catch (error) {
// Don't retry on unique constraint violation – the commission already exists
// (likely a race between the dedup check and the create)
if (error.code === "P2002") {
console.log(
`Referral commission already exists for invoiceId ${commissionData.invoiceId}, skipping creation.`,
);
return null;
}
console.error(
"Error creating network referral commission",
error,
commissionData,
);
await log({
message: `[createNetworkReferralCommission] Error creating referral commission - ${error.message}`,
type: "errors",
mention: true,
});
throw error;
}
```
Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:238-267](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L238-L267)
> [!NOTE]
> Unique constraint violations (`P2002`) during network referral commission creation are caught and ignored rather than retried, preventing duplicate insertion race conditions when an invoice ID already exists.
Sources: [apps/web/lib/partner-referrals/create-network-referral-commission.ts:244-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts#L244-L252)
### Commission Interception Parameters and Constants
| Parameter / Constant | Target Scope | Purpose |
| :--- | :--- | :--- |
| `PARTNER_LEVEL_FRAUD_RULES` | Partner-level | Filters fraud event groups for partner-scope rule violations (`type: { in: PARTNER_LEVEL_FRAUD_RULES }`). Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:641-644](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L641-L644) |
| `FraudEventStatus.pending` | Fraud Event Group | Queries unresolving risk groups requiring commission interception. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:640](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L640) |
| `CommissionStatus.hold` | Commission Record | Target status assigned to intercepted commissions when risk rules or pending groups trigger. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:674](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L674) |
| `P2002` | Database Error Code | Identifies unique constraint collisions to prevent duplicate commission insertion retries. Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:516](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L516) |
## Commission Hold and Payout Retallying
### Overview
When partner-level fraud triggers violations such as duplicate identities, matching payout methods, or network-level bans, bulk commission hold routines execute against both pending and processed commission records.
Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:14-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L14-L18)
### Pending Commission Batching and Updates
`holdPendingCommissions()` processes program enrollments by mapping unique partner-program pairs and chunking them into batches of 50.
Sources: [apps/web/lib/api/fraud/hold-pending-commissions.ts:10-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L10-L24)
```typescript
export async function holdPendingCommissions(
programEnrollments: Pick[],
) {
if (programEnrollments.length === 0) {
console.log("No program enrollments to hold pending commissions for");
return;
}
const uniquePairs = [
...new Map(
programEnrollments.map((e) => [`${e.programId}:${e.partnerId}`, e]),
).values(),
];
const chunks = chunk(uniquePairs, 50);
const holdEligibleWhere: Prisma.CommissionWhereInput = {
status: CommissionStatus.pending,
earnings: {
gt: 0,
},
program: {
workspace: {
plan: {
in: ["enterprise", "advanced"],
},
},
},
};
```
Sources: [apps/web/lib/api/fraud/hold-pending-commissions.ts:10-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L10-L38)
> [!WARNING]
> Only commissions belonging to `enterprise` or `advanced` workspace plans with positive earnings (`earnings > 0`) are eligible for holding.
Sources: [apps/web/lib/api/fraud/hold-pending-commissions.ts:28-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-pending-commissions.ts#L28-L37)
### Processed Commission Handling and Payout Retallying
`holdProcessedCommissions()` queries processed commissions attached to pending payouts, updates their status to `hold`, sets their `payoutId` to `null`, and collects affected payout IDs into a tracking set.
Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:32-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L32-L101)
```typescript
// need to retally payouts to ensure the payout amount is correct
await retallyPayoutsAmount(Array.from(payoutIdsToRetallySet));
```
Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:168-170](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L168-L170)
> [!NOTE]
> Because updating processed commissions detaches them from their pending payouts (`payoutId: null`), `retallyPayoutsAmount()` is invoked at the end of execution to recalculate correct payout totals.
Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:98-100](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L98-L100)
### Commission Hold Parameters and Configuration
| Parameter / Constant | Target Scope | Purpose |
| :--- | :--- | :--- |
| `PRISMA_UPDATEMANY_LIMIT` | Query Batch Size | Limits the maximum number of commission rows retrieved per iteration. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L76) |
| `CommissionStatus.processed` | Commission Status | Initial state required for processed commissions before being transitioned to hold. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L33) |
| `PayoutStatus.pending` | Payout Status | Required payout status restriction when querying processed commissions for holding. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:34-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L34-L36) |
| `syncTotalCommissions` | Partner Totals | Recalculates total partner commissions for affected partner-program pairs after status updates. Sources: [apps/web/lib/api/fraud/hold-processed-commissions.ts:157-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/hold-processed-commissions.ts#L157-L164) |
## Automated Hold Releases and Expiration
### Automated Hold Releases and Expiration
Scheduled cron jobs and cleanup handlers manage the lifecycle of fraud groups and release held commissions back to a pending state once risks are resolved or expiration thresholds are met.
Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:1-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L1-L18)
### Fraud Group Expiration and Cron Orchestration
The expiration cleanup cron route (`POST /api/cron/cleanup/expired-fraud-groups`) executes once every day at 02:30:00 AM UTC (`30 2 * * *`). It queries pending fraud event groups that have exceeded their time-to-live threshold and transitions their status to expired.
```typescript
const groupsToExpire = await prisma.fraudEventGroup.findMany({
where: {
status: "pending",
type: {
notIn: NON_EXPIRING_FRAUD_RULE_TYPES,
},
lastEventAt: {
lt: subDays(new Date(), FRAUD_GROUP_EXPIRY_DAYS),
},
},
select: {
id: true,
},
take: BATCH_SIZE,
});
```
Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:21-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L21-L36)
> [!NOTE]
> `FRAUD_GROUP_EXPIRY_DAYS` defines the inactivity window (defaulting to 30 days based on `lastEventAt`), and rule types specified in `NON_EXPIRING_FRAUD_RULE_TYPES` are explicitly excluded from automatic expiration.
Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:1-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L1-L30)
### Commission Release Logic and Safety Checks
When fraud groups are resolved via `resolveFraudGroups()` or marked expired during cleanup, `queueReleaseHoldCommissions()` triggers downstream hold releases. The core release function `releaseHoldCommissions()` performs strict verification before altering any commission states:
1. **Partner-Level Check:** It queries remaining pending fraud groups for the partner. If any active partner-level fraud rules remain, all hold commissions are kept on hold.
2. **Customer-Level Filtering:** If other pending groups exist, customer IDs tied to customer-level fraud rules are collected into a `blockedCustomerIds` set.
3. **Selective Release:** Commissions tied to blocked customer IDs remain in `CommissionStatus.hold`, whereas custom commissions (`customerId: null`) and commissions linked to unblocked customers transition from `hold` to `pending`.
```typescript
export async function releaseHoldCommissions({
programId,
partnerId,
resolvedGroupIds,
}: {
programId: string;
partnerId: string;
resolvedGroupIds: string[];
}) {
if (resolvedGroupIds.length === 0) {
return 0;
}
...
```
Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:35-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L35-L48)
> [!WARNING]
> If a partner maintains any active partner-level pending fraud group across the program, the entire commission release batch for that partner is skipped, regardless of whether specific conversion groups have been resolved.
Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:68-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L68-L77)
### Downstream Orchestration Steps
Once commissions are successfully moved to `CommissionStatus.pending`, `releaseHoldCommissions()` executes concurrent post-update procedures via `Promise.allSettled()`:
```typescript
const results = await Promise.allSettled([
trackCommissionStatusUpdate({
workspaceId: program.workspaceId,
programId,
commissions: releasedCommissions,
newStatus: CommissionStatus.pending,
}),
syncTotalCommissions({
partnerId,
programId,
}),
triggerAggregateDueCommissionsCronJob(programId),
releasedEarnings > 0 &&
executeWorkflows({
event: "commissionRecorded",
identity: {
workspaceId: program.workspaceId,
programId,
partnerId,
},
metrics: {
current: {
commissions: releasedEarnings,
},
},
}),
]);
```
Sources: [apps/web/lib/api/fraud/release-hold-commissions.ts:191-218](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-hold-commissions.ts#L191-L218)
Additionally, program downgrades or manual overrides can execute `releaseAllHoldCommissions()`, which bypasses customer-level filtering and releases all hold commissions belonging to a specific program.
Sources: [apps/web/lib/api/fraud/release-all-hold-commissions.ts:8-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/release-all-hold-commissions.ts#L8-L28)
## Risk Center and Audit Operations
### Overview
The Risk Center interface and its associated audit operations provide workspace administrators with tooling to review flagged fraud event groups, examine associated commissions on hold, and execute backfill migrations for retroactively applying hold rules. Access to fraud risk layouts is governed by workspace plan capabilities via `getPlanCapabilities()`, which evaluates `canManageFraudEvents` and displays a `RiskCenterUpsell` component when capabilities are unavailable.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/risks/layout.tsx:1-24](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/risks/layout.tsx#L1-L24)
### Risk Events Table and Associated Commissions Audit
The `RiskEventsTable` component retrieves pending fraud groups using `useFraudGroups` and integrates filters, batch action modals, and review sheets. When investigating a specific risk group, the `AssociatedCommissionsTable` queries held commissions through the `/api/commissions` endpoint with a designated `fraudEventGroupId` and `status: "hold"`.
```typescript
const query = {
workspaceId: workspaceId!,
status: "hold",
fraudEventGroupId: fraudGroup.id,
partnerId: fraudGroup.partner.id,
};
```
Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:39-44](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L39-L44)
The table configures columns for creation date, associated customer row items, commission types via `CommissionTypeBadge`, currency-formatted amounts, and action menus, with row auxiliary click handlers linking directly to individual commission detail views.
Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:70-159](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L70-L159)
> [!NOTE]
> Associated commission queries utilize `keepPreviousData: true` alongside SWR fetchers to maintain UI stability during pagination across hold records.
Sources: [apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx:57-67](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/fraud-risks/associated-commissions-table.tsx#L57-L67)
### Backfill and Maintenance Scripts
When hold-on-fraud rules are introduced or updated, backfill scripts such as `backfill-hold-pending-commissions.ts` and `backfill-hold-processed-commissions.ts` scan pending `FraudEventGroup` records in batches of 50 and transition matching eligible commissions to `CommissionStatus.hold`.
| Script File | Target Status | Batch Size | Post-Update Operations |
| :--- | :--- | :--- | :--- |
| `backfill-hold-pending-commissions.ts` | `CommissionStatus.pending` | 50 | Tracks status updates, syncs total commissions |
| `backfill-hold-processed-commissions.ts` | `CommissionStatus.processed` | 50 | Tracks updates, syncs totals, retalls payouts |
Sources: [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:19-240](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts#L19-L240)
> [!WARNING]
> Because `updateMany` re-checks eligibility, a commission's status can change between `findMany` and `updateMany` (e.g., if a payout is updated). The scripts explicitly re-verify held IDs before tracking activity logs or syncing partner totals.
Sources: [apps/web/scripts/migrations/backfill-hold-processed-commissions.ts:177-199](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/migrations/backfill-hold-processed-commissions.ts#L177-L199)
## Related
- [[Commission Rules and Rewards]]
- [[Identity Verification]]
---
## Technical docs: GET Get network partners count
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadminnetworkpartnerscount
## Parameters
## Responses
## Try It
---
## Technical docs: Network and Marketplace
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/affiliate-platform/network-and-marketplace
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/prisma/schema/network.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/network.prisma)
- [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts)
- [apps/web/app/ee/api/network/partners/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/network/partners/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/ui/program-marketplace/program-marketplace-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/program-marketplace-card.tsx)
- [packages/ui/src/nav/content/program-marketplace.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/content/program-marketplace.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page.tsx)
- [apps/web/lib/api/network/calculate-partner-ranking.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/network/calculate-partner-ranking.ts)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx)
- [apps/web/lib/network/get-program-network-invite-email-defaults.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/network/get-program-network-invite-email-defaults.ts)
- [apps/web/ui/program-marketplace/program-marketplace-banner.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/program-marketplace-banner.tsx)
- [apps/web/app/ee/api/admin/partners/partnerId/shared-platforms/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/shared-platforms/route.ts)
- [apps/web/lib/api/partners/get-network-invites-usage.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/get-network-invites-usage.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/network-upsell.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/network-upsell.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/profile/network-approval-guide.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/network-approval-guide.tsx)
- [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts)
- [apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/external/marketplace-external-program-page.tsx)
- [apps/web/app/ee/partners.dub.co/auth-other/invite/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx)
- [apps/web/ui/program-marketplace/featured-program-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/featured-program-card.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/invitations/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/invitations/page-client.tsx)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/ui/program-marketplace/marketplace-program-hero.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-hero.tsx)
- [apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx)
- [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts)
- [apps/web/app/sitemap.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts)
- [apps/web/app/app.dub.co/marketplace/...segments/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/%5B%5B...segments%5D%5D/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/invitations/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/invitations/page.tsx)
- [packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx)
## Overview
The Dub Network and Marketplace system powers affiliate program discovery, similarity scoring, partner ranking algorithms, workspace recruitment dashboards, public marketplace routing, and structured invitation lifecycles. It connects operators seeking high-performing affiliates with partners looking for relevant SaaS programs across multiple categories.
Sources: [apps/web/prisma/schema/network.prisma:1-40](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/network.prisma#L1-L40), [apps/web/lib/api/network/calculate-partner-ranking.ts:1-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/network/calculate-partner-ranking.ts#L1-L39)
The platform solves complex matching and recruitment challenges by utilizing automated cron pipelines for similarity evaluation, multi-factor ranking heuristics for discovery, comprehensive workspace control centers, and streamlined onboarding and application flows for partners.
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:25-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L25-L50), [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:71-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L71-L122)
## Program Similarity Calculation Engine
### Overview
The Program Similarity Calculation Engine runs as an automated cron pipeline executing once every 12 hours via `POST /api/cron/network/calculate-program-similarities`. It evaluates active affiliate programs in batches of 10 (`PROGRAMS_PER_BATCH = 10`), verifying QStash signatures and computing multi-dimensional similarity scores across shared categories, overlapping partners, and performance metrics.
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:25-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L25-L50), [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:1-24](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L1-L24)
### Execution Pipeline and Call Chain
The similarity computation engine processes programs sequentially using pagination cursors and database transactions. The primary execution flow follows this strict call chain:
`POST` handler (`route.ts`) to `verifyQstashSignature()` to `calculateProgramSimilarity()` to `findNextProgram()` to `prisma.program.findMany()` to `Promise.all([calculateCategorySimilarity(), calculatePartnerSimilarity(), calculatePerformanceSimilarity()])` to `prisma.$transaction()` to `qstash.publishJSON()`
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:30-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L30-L50), [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:52-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L52-L113), [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:168-214](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L168-L214)
> [!WARNING]
> Programs are only eligible for comparison if they are non-deactivated (`deactivatedAt: null`) and have either been added to the marketplace (`addedToMarketplaceAt: not: null`) or have the partner network enabled (`partnerNetworkEnabledAt: not: null`).
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:64-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L64-L82)
### Scoring Algorithms and Weights
The final similarity score between two programs combines three distinct similarity sub-scores with fixed weighting coefficients:
Similarity Score equals `categoryScore` times 0.5 plus `partnerScore` times 0.3 plus `performanceScore` times 0.2.
If the calculated similarity score exceeds `PROGRAM_SIMILARITY_SCORE_THRESHOLD`, bidirectional records are pushed into the `ProgramSimilarity` table within a Prisma transaction, replacing previous entries for those program IDs.
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:132-184](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L132-L184)
| Dimension | Weight | Calculation Method | Source File |
| --- | --- | --- | --- |
| Category Similarity | 0.5 | Jaccard similarity coefficient on `ProgramCategory` records (`sharedCount / totalUniqueCount`) | [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts:4-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts#L4-L43) |
| Partner Similarity | 0.3 | Jaccard similarity coefficient on overlapping `ProgramEnrollment` partner IDs (`sharedPartnersCount / unionCount`) | [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts:10-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts#L10-L44) |
| Performance Similarity | 0.2 | Comparative performance metrics evaluated concurrently via promise resolution | [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:122-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L122-L130) |
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:132-135](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L132-L135), [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts:4-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts#L4-L43), [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts:10-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts#L10-L44)
> [!TIP]
> Both category and partner similarity return `0` immediately if the denominator (`totalUniqueCount` or `unionCount`) evaluates to zero, preventing division-by-zero exceptions during similarity calculations.
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts:38-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts#L38-L41), [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts:39-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts#L39-L42)
### Database Schema and Design Trade-offs
The `ProgramSimilarity` model relies on a composite unique constraint and indexes to maintain fast lookup performance for similarity recommendations.
```prisma
model ProgramSimilarity {
id String @id @default(cuid())
programId String
similarProgramId String
similarityScore Float
categorySimilarityScore Float
partnerSimilarityScore Float
performanceSimilarityScore Float
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
similarProgram Program @relation("SimilarProgram", fields: [similarProgramId], references: [id], onDelete: Cascade)
@@unique([programId, similarProgramId])
@@index([programId, similarityScore])
@@index(similarProgramId)
}
```
Sources: [apps/web/prisma/schema/network.prisma:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/network.prisma#L25-L40)
| Design Choice | Benefit | Cost |
| --- | --- | --- |
| Bidirectional pairing insertion | O(1) read performance when querying similar programs for any given program ID | Doubles storage volume in `ProgramSimilarity` for every qualified pair |
| QStash batch queueing (`PROGRAMS_PER_BATCH = 10`) | Prevents cron timeouts by distributing heavy calculations across iterative asynchronous tasks | Extends total pipeline completion time across multiple scheduled dispatches |
| Raw SQL for partner Jaccard aggregation | Executes partner overlap counts efficiently directly within database memory | Ties query logic directly to SQL syntax and database schema foreign keys |
Sources: [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L25), [apps/web/app/ee/api/cron/network/calculate-program-similarities/route.ts:147-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts#L147-L165), [apps/web/app/ee/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts:14-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts#L14-L25), [apps/web/prisma/schema/network.prisma:25-40](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/network.prisma#L25-L40)
## Partner Network Ranking and Discovery
### Overview
The partner network API provides programmatic filtering, scoring heuristics, and ranking calculations for discoverable network partners. Workspace operators query available network partners via endpoints that evaluate program similarity scores (`similarityScore > PROGRAM_SIMILARITY_SCORE_THRESHOLD`), process query parameters, and execute ranking algorithms or listing filters depending on the requested status.
Sources: [apps/web/app/ee/api/network/partners/route.ts:16-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/network/partners/route.ts#L16-L56), [apps/web/lib/api/network/calculate-partner-ranking.ts:14-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/network/calculate-partner-ranking.ts#L14-L29)
### API Request Handling and Call-Chain Execution
When a GET request hits `/api/network/partners`, the endpoint executes a strict sequence of validation and database operations to fetch and format records.
The request processing follows this exact call chain:
1. `withWorkspace()` — Validates workspace authentication and extracts workspace context along with request search parameters.
2. `getDefaultProgramIdOrThrow()` — Resolves the default program identifier for the current workspace.
3. `prisma.program.findUniqueOrThrow()` — Fetches the target program record including its related `similarPrograms` filtered by `similarityScore` greater than `PROGRAM_SIMILARITY_SCORE_THRESHOLD`, ordered descending, taking up to 10 records.
4. `getNetworkPartnersQuerySchema.parse()` — Validates query parameters including `partnerIds`, `status`, `page`, `pageSize`, `country`, `starred`, `sortBy`, and `platform`.
5. Branching execution:
- If `status` is **not** `"discover"`: executes `partnerNetworkListingWhere()`, queries `prisma.discoveredPartner.findMany()`, and maps results through `NetworkPartnerSchema.parse()`.
- If `status` is `"discover"`: invokes `calculatePartnerRanking()` passing the structured parameters and similar programs list, then parses and formats the returned ranking array.
Sources: [apps/web/app/ee/api/network/partners/route.ts:17-132](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/network/partners/route.ts#L17-L132)
> [!WARNING]
> If `program.partnerNetworkEnabledAt` is null or undefined, the endpoint immediately throws a `DubApiError` with code `"forbidden"` and stops execution, preventing any partner queries from running on disabled programs.
Sources: [apps/web/app/ee/api/network/partners/route.ts:40-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/network/partners/route.ts#L40-L45)
### Partner Ranking Scoring Heuristics
For partners in the `"discover"` tab, `calculatePartnerRanking()` computes a composite score ranging from 0 to over 265 points. The scoring model aggregates priority bonuses, similarity performance, and program matches.
| Component | Point Range | Heuristic Details |
| --- | --- | --- |
| Trusted Partner Bonus | 200 points | Awarded to partners with `networkStatus = "trusted"` to ensure top placement |
| Similarity Score | 0–50 points | Sums weighted performance across similar programs where `similarityScore > 0.3` (consistency 20%, conversion rate 10%, LTV 15%, commissions 5%) |
| Program Match Score | 0–15 points | Awards 2 points per similar program the partner is enrolled in, capped at 15 points |
Sources: [apps/web/lib/api/network/calculate-partner-ranking.ts:18-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/network/calculate-partner-ranking.ts#L18-L34)
The sorting order of discovered partners is determined by `buildOrderByClause()`, which handles pinned or starred partners, platform subscriber counts, and relevance ranking.
```typescript
function buildOrderByClause({
starred,
sortBy,
platform,
}: {
starred?: boolean | null;
sortBy?: "relevance" | "subscribers";
platform?: PlatformType;
}) {
if (starred === true) {
return Prisma.sql`dp.starredAt DESC`;
}
if (sortBy === "subscribers" && platform) {
return Prisma.sql`(
SELECT COALESCE(MAX(pp_sort.subscribers), 0)
FROM PartnerPlatform pp_sort
WHERE pp_sort.partnerId = p.id
AND pp_sort.type = ${platform}
AND pp_sort.verifiedAt IS NOT NULL
) DESC, p.id ASC`;
}
return Prisma.sql`finalScore DESC, p.id ASC`;
}
```
Sources: [apps/web/lib/api/network/calculate-partner-ranking.ts:40-64](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/network/calculate-partner-ranking.ts#L40-L64)
### Shared Platform Identification
Administrators can inspect other network partners sharing the same verified platform identifiers via `/api/admin/partners/[partnerId]/shared-platforms`. The endpoint retrieves the target partner's verified platforms, normalizes website identifiers to domain names using `getDomainWithoutWWW()`, and queries matching records across other partner accounts.
Sources: [apps/web/app/ee/api/admin/partners/partnerId/shared-platforms/route.ts:10-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/shared-platforms/route.ts#L10-L87)
> [!NOTE]
> Website platform matching uses a `contains` clause on the extracted website domain rather than an exact identifier match, followed by a strict verification check to ensure domain equality and filter out partial string collisions.
Sources: [apps/web/app/ee/api/admin/partners/partnerId/shared-platforms/route.ts:39-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/shared-platforms/route.ts#L39-L51), [apps/web/app/ee/api/admin/partners/partnerId/shared-platforms/route.ts:97-103](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/shared-platforms/route.ts#L97-L103)
## Workspace Partner Network UI
### Overview
The dashboard interface for the partner network provides program operators with tools to discover, filter, inspect, and evaluate potential partners. Built around `ProgramPartnerNetworkPageClient`, the dashboard manages state across multiple tabs, platform filters, star rankings, and partner detail sheets.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:71-391](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L71-L391)
### Dashboard Tabs and State Management
The interface organizes partner management into three main tabs: `discover`, `invited`, and `recruited`. Operators can also access an ignored variant view. State synchronization relies heavily on URL query parameters handled by `useRouterStuff()` and SWR data fetching.
| Tab ID | Label | Description |
| --- | --- | --- |
| `discover` | Discover | Displays prospective network partners available for recruitment |
| `invited` | Invited | Lists partners who have received an invitation to the program |
| `recruited` | Recruited | Displays partners who have successfully joined the program |
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:39-52](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L39-L52), [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:67-81](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L67-L81)
> [!NOTE]
> When switching between tabs via `queryParams`, parameters such as `page`, `starred`, and `sortBy` are automatically cleared to prevent invalid filter states across different list categories.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:201-206](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L201-L206)
### Platform Filters and Toggle Options
Within the `discover` tab, operators can filter potential partners by their connected social platforms or website presence using a toggle group component.
| Platform Value | Associated Icon |
| --- | --- |
| `all` | User |
| `website` | Globe |
| `youtube` | YouTube |
| `twitter` | Twitter |
| `linkedin` | LinkedIn |
| `instagram` | Instagram |
| `tiktok` | TikTok |
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:54-65](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L54-L65), [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:224-259](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L224-L259)
### Partner Inspection and Navigation Walkthrough
Operators can click on any partner card to open a detailed sheet (`NetworkPartnerSheet`). The component handles deep-linking via search parameters and computes adjacent navigation items for quick traversal.
The inspection lifecycle executes through the following sequence:
1. `useEffect()` inspects the URL search parameters for a `partnerId`. If present, it updates `detailsSheetState` to open the sheet.
2. `useCurrentPartner()` evaluates whether the target partner exists in the loaded array (`partners`); if not, it fetches the specific partner via SWR from `/api/network/partners`.
3. `useMemo()` calculates `previousPartnerId` and `nextPartnerId` by finding the index of the current partner within the loaded list.
4. Triggering `onPrevious` or `onNext` invokes `queryParams({ set: { partnerId: previousPartnerId } })`, updating the active sheet view without closing the container.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:133-183](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L133-L183), [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/page-client.tsx:393-425](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/page-client.tsx#L393-L425)
> [!WARNING]
> If a workspace lacks enterprise permissions, the network view falls back to `NetworkUpsell`, rendering a preview cell list and prompting the user to upgrade to Enterprise or contact sales.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/program/network/network-upsell.tsx:9-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/network/network-upsell.tsx#L9-L44)
## Marketplace Directory and Program Display
### Overview
The public and authenticated marketplace routing system exposes program directories, category pages, individual program views, and promotional UI components. Routing is governed by dynamic Next.js segments under `/marketplace/[[...segments]]/page.tsx`, which inspects URL segments to resolve individual network programs or category filters. Public promotional elements include `ProgramMarketplaceBanner` and `ProgramMarketplaceCard`, which check partner profile SWR hooks and promo status states before rendering animated background grids and floating logo containers.
Sources: [apps/web/app/app.dub.co/marketplace/...segments/page.tsx:14-67](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/%5B%5B...segments%5D%5D/page.tsx#L14-L67), [apps/web/ui/program-marketplace/program-marketplace-card.tsx:12-20](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/program-marketplace-card.tsx#L12-L20), [apps/web/ui/program-marketplace/program-marketplace-banner.tsx:12-18](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/program-marketplace-banner.tsx#L12-L18)
### Marketplace Routing and Metadata Generation
Dynamic marketplace routing resolves parameters through asynchronous `generateMetadata` functions and page components. When visitors navigate to marketplace URLs, the segment matcher evaluates whether the path requests the all-programs list, a specific program slug, or a category prefix (`/c/`).
The metadata generation sequence executes through the following validation checks:
1. `generateMetadata()` reads `params` to extract the `segments` array, defaulting to an empty list for the marketplace home root.
2. If `segments.length === 1` and equals `"all"`, it sets the title to top SaaS affiliate programs for the current year.
3. If `segments.length === 1` and matches a non-reserved string, it invokes `getNetworkProgram({ slug: segments[0] })` to fetch program details, overriding title, description, and OG image fields.
4. If `segments.length === 2` and `segments[0] === "c"`, it scans `Category` enums to match `segments[1]`, resolving category-specific labels and API OpenGraph endpoints.
Sources: [apps/web/app/app.dub.co/marketplace/...segments/page.tsx:14-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/%5B%5B...segments%5D%5D/page.tsx#L14-L60)
> [!NOTE]
> When `domain` matches `PARTNERS_HOSTNAMES`, the sitemap generator queries Prisma for programs containing published lander data (`landerData: { not: Prisma.AnyNull }` and `landerPublishedAt: { not: null }`), outputting custom partner lander URLs.
Sources: [apps/web/app/sitemap.ts:20-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts#L20-L44)
### Program Display Components and Hero Layouts
Programs are rendered across discovery grids and detail views using specialized components such as `MarketplaceProgramHero`, `FeaturedProgramCard`, and `MarketplaceProgramsListPage`.
| Component File | Primary Role | Key Props & Dependencies |
| --- | --- | --- |
| `marketplace-program-hero.tsx` | Renders top banner image, avatar, title, description, categories, website links, and application slots | `program`, `applySlot`, `className` |
| `featured-program-card.tsx` | Displays featured program cards with dynamic background tinting and reward summaries | `program`, `externalMarketplace`, `colorIndex` |
| `marketplace-programs-list-page.tsx` | Manages list fetching, SWR caching, pagination controls, and empty-state filtering | None (Client Component) |
Sources: [apps/web/ui/program-marketplace/marketplace-program-hero.tsx:13-21](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-hero.tsx#L13-L21), [apps/web/ui/program-marketplace/featured-program-card.tsx:35-43](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/featured-program-card.tsx#L35-L43), [apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx:19-42](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/pages/marketplace-programs-list-page.tsx#L19-L42)
> [!IMPORTANT]
> `FeaturedProgramCard` and `MarketplaceProgramHero` use `useImageAccentColor` to extract dominant color tints from program header images or logos, falling back to predefined palette arrays (`FEATURED_CARD_BACKGROUNDS`) if image extraction is unavailable.
Sources: [apps/web/ui/program-marketplace/featured-program-card.tsx:14-52](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/featured-program-card.tsx#L14-L52), [apps/web/ui/program-marketplace/marketplace-program-hero.tsx:23-26](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-hero.tsx#L23-L26)
### Sitemap Generation Logic
The sitemap generator in `apps/web/app/sitemap.ts` inspects incoming request headers to determine host environments and construct matching XML sitemap entries.
```typescript
export default async function sitemap(): Promise {
const headersList = await headers();
let domain = headersList.get("host") as string;
if (domain === "dub.localhost:8888") {
domain = SHORT_DOMAIN;
}
if (PARTNERS_HOSTNAMES.has(domain)) {
const programs = await prisma.program.findMany({
where: { groups: { some: { slug: "default", landerData: { not: Prisma.AnyNull }, landerPublishedAt: { not: null } } } },
orderBy: { slug: "asc" },
});
return programs.map((program) => ({
url: `https://partners.dub.co/${program.slug}`,
lastModified: new Date(),
}));
}
const entries: MetadataRoute.Sitemap = [{ url: `https://${domain}`, lastModified: new Date() }];
if (isAppHostname(domain)) {
const marketplacePrograms = await prisma.program.findMany({
where: { addedToMarketplaceAt: { not: null } },
select: { slug: true, updatedAt: true },
orderBy: { slug: "asc" },
});
entries.push(
{ url: "https://dub.co/marketplace", lastModified: new Date() },
{ url: `https://dub.co${getMarketplaceAllHref()}`, lastModified: new Date() },
...Object.values(Category).map((category) => ({
url: `https://dub.co${getMarketplaceCategoryHref(category)}`,
lastModified: new Date(),
})),
...marketplacePrograms.map((program) => ({
url: `https://dub.co/marketplace/${program.slug}`,
lastModified: program.updatedAt,
})),
);
}
return entries;
}
```
Sources: [apps/web/app/sitemap.ts:11-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/sitemap.ts#L11-L90)
## Network Invitation Lifecycle and Quotas
### Overview
Managing network invitations involves monitoring monthly workspace quotas, orchestrating the partner acceptance lifecycle, and supplying clean default parameters for outbound invitation emails. Workspace invitation limits are determined by aggregating discovered partner metrics against billing cycle start dates, while partner acceptance workflows handle asynchronous API submissions, session refreshes, and cache invalidation.
Sources: [apps/web/lib/api/partners/get-network-invites-usage.ts:5-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/get-network-invites-usage.ts#L5-L30), [apps/web/app/ee/partners.dub.co/auth-other/invite/page.tsx:18-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx#L18-L42), [apps/web/lib/network/get-program-network-invite-email-defaults.ts:13-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/network/get-program-network-invite-email-defaults.ts#L13-L27)
### Monthly Invite Usage Tracking
Workspace quota utilization for network invitations is calculated in `getNetworkInvitesUsage` via Prisma database aggregation. The query counts `DiscoveredPartner` records tied to the workspace's programs where either the invitation timestamp or messaging timestamp falls after the current billing cycle start date.
```typescript
export async function getNetworkInvitesUsage(
workspace: Pick,
) {
const invites = await prisma.discoveredPartner.aggregate({
_count: true,
where: {
program: {
workspaceId: workspace.id,
},
OR: [
{
invitedAt: {
gt: getBillingStartDate(workspace.billingCycleStart),
},
},
{
messagedAt: {
gt: getBillingStartDate(workspace.billingCycleStart),
},
},
],
},
});
return invites._count;
}
```
Sources: [apps/web/lib/api/partners/get-network-invites-usage.ts:5-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/get-network-invites-usage.ts#L5-L30)
> [!NOTE]
> `getNetworkInvitesUsage` tracks usage by evaluating both `invitedAt` and `messagedAt` timestamps against `getBillingStartDate(workspace.billingCycleStart)`. A partner interaction triggers quota consumption if either event occurred within the active billing period.
Sources: [apps/web/lib/api/partners/get-network-invites-usage.ts:14-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/partners/get-network-invites-usage.ts#L14-L25)
### Partner Acceptance Workflow
The partner invite acceptance page (`AcceptPartnerInvitePage`) manages user onboarding onto partner profiles through client-side state handling and SWR cache mutations.
```mermaid
sequenceDiagram
participant User
participant Page as AcceptPartnerInvitePage
participant API as /api/partner-profile/invites/accept
participant Session as NextAuth Session
participant SWR as SWR Cache
User->>Page: Click "Accept invite"
Page->>API: POST /api/partner-profile/invites/accept
alt Response OK
API-->>Page: Success
Page->>Session: refreshSession()
Page->>SWR: mutatePrefix("/api/partner-profile")
Page->>User: router.replace("/programs") & toast.success()
else Response Error
API-->>Page: Error JSON
Page->>User: setAccepting(false) & toast.error()
end
```
Sources: [apps/web/app/ee/partners.dub.co/auth-other/invite/page.tsx:18-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx#L18-L42)
> [!WARNING]
> If a user navigates to the invite page while already associated with an active partner profile (`!loading && partner`), the client immediately executes `router.replace("/programs")` and halts rendering.
Sources: [apps/web/app/ee/partners.dub.co/auth-other/invite/page.tsx:44-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-other)/invite/page.tsx#L44-L48)
### Invitation Email Defaults and Partner Name Sanitization
Outbound email generation relies on helper utilities in `get-program-network-invite-email-defaults.ts` to sanitize recipient names. Because some network partners have raw email addresses stored in place of names, `getUsableNetworkPartnerName` checks for the presence of the `@` symbol and strips invalid entries.
| Utility Function | Input Parameter | Return Type | Behavior / Sanitization Rule |
| --- | --- | --- | --- |
| `getUsableNetworkPartnerName` | `name?: string \| null` | `string \| null` | Trims input; returns `null` if empty or if string contains `@`. |
| `getNetworkPartnerDisplayName` | `name?: string \| null` | `string` | Delegates to `getUsableNetworkPartnerName`; falls back to `"there"`. |
| `getProgramNetworkInviteEmailDefaults` | `{ programName: string, partnerName?: string \| null }` | `{ subject, title, body }` | Constructs template strings using the sanitized partner display name. |
Sources: [apps/web/lib/network/get-program-network-invite-email-defaults.ts:3-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/network/get-program-network-invite-email-defaults.ts#L3-L27)
> [!TIP]
> When generating program invite copy, `getNetworkPartnerDisplayName` ensures that email addresses stored as partner names never leak into outbound notification copy, substituting a friendly fallback like `"there"` automatically.
Sources: [apps/web/lib/network/get-program-network-invite-email-defaults.ts:1-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/network/get-program-network-invite-email-defaults.ts#L1-L11)
## Partner Profile and Application Flow
### Overview
The partner profile and application flow govern how partners onboard onto the network, fulfill requirements, submit network applications, and interact with support through Plain webhooks. Partner progression relies on checklist completion, status validation, and state-driven UI controls.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/network-approval-guide.tsx:40-148](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/network-approval-guide.tsx#L40-L148), [apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx:49-189](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx#L49-L189)
### Partner Onboarding Checklist and Approval Guide
The `NetworkApprovalGuide` component renders the interactive onboarding workflow for partners inside the partner dashboard. It tracks checklist progress, handles draft submissions via `submitNetworkProfileAction`, and dynamically reflects partner approval states.
| Network Status | Badge Variant | Icon | Label |
| --- | --- | --- | --- |
| `submitted` | `pending` | `CircleHalfDottedClock` | Pending approval |
| `rejected` | `error` | `CircleXmark` | Rejected |
| `approved` / `trusted` | `success` | `CircleCheck` | Approved |
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/network-approval-guide.tsx:22-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/network-approval-guide.tsx#L22-L38), [apps/web/app/ee/partners.dub.co/dashboard/profile/network-approval-guide.tsx:110-127](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/network-approval-guide.tsx#L110-L127)
> [!NOTE]
> If a partner's network status is evaluated as `trusted`, the approval guide explicitly normalizes it to display as `approved` using the standard success badge variant.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/network-approval-guide.tsx:110-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/network-approval-guide.tsx#L110-L113)
### Program Application Controls and Eligibility Logic
The `MarketplaceProgramHeaderControls` component manages program enrollment actions, toggling between invite acceptance, dashboard redirection, and application sheet triggers based on current enrollment status.
```mermaid
graph TD
A[Program Enrollment Status] -->|invited| B[AcceptInviteButton]
A[Approved] -->|approved| C[View Dashboard Link]
A[Other / None] -->|default| D[ApplyButton / Sheet]
```
Sources: [apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx:30-47](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx#L30-L47)
> [!WARNING]
> Program application buttons remain disabled if checklist tasks are incomplete, the partner network status is outside `approved` or `trusted`, or the applicant fails custom program requirements evaluated by `evaluateApplicationRequirements`.
Sources: [apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx:104-170](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/program-marketplace/marketplace-program-header-controls.tsx#L104-L170)
### Support Integrations via Plain Webhooks
The Plain integration route (`/api/callback/plain/partner`) authenticates incoming webhooks using the `X-Plain-Webhook-Secret` header, resolves partner profiles via database lookups, and hydrates Plain customer cards with financial and account identifiers.
```typescript
export async function POST(req: NextRequest) {
const token = req.headers.get("X-Plain-Webhook-Secret");
if (token !== process.env.PLAIN_WEBHOOK_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
let { customer } = plainCallbackSchema.parse(await req.json());
if (!customer.externalId) {
const user = await prisma.user.findUnique({
where: { email: customer.email },
});
if (!user || !user.email) {
return NextResponse.json({
cards: [{ key: "partner", components: [plainEmptyContainer("No user found.")] }],
});
}
customer.externalId = user.id;
await upsertPlainCustomer({ id: user.id, name: user.name, email: user.email });
}
// ...fetches partner profile and renders customer view cards
}
```
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L55)
> [!TIP]
> When a webhook lacks an `externalId`, the route automatically queries the `User` table by email, provisions the external ID link, and calls `upsertPlainCustomer` before attempting partner profile matching.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:31-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L31-L55)
## Related
- [[Partner Program Management]]
- [[Partner Portal and Onboarding]]
---
## Technical docs: GET Get network partners list
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadminnetworkpartners
## Parameters
## Responses
## Try It
---
## Technical docs: Stripe Billing and Webhooks
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/stripe-billing-and-webhooks
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts)
- [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx)
- [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts)
- [apps/web/app/ee/api/stripe/connect/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/api/callback/plain/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts)
- [apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts)
- [apps/web/app/api/webhooks/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts)
- [apps/web/lib/swr/use-webhooks.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-webhooks.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/payment-methods/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/payment-methods/route.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/api/webhooks/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts)
- [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/manage/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/manage/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/invoices/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/invoices/route.ts)
- [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts)
- [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts)
- [apps/web/app/ee/api/intercom/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/retry-payment/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/retry-payment/route.ts)
- [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts)
- [apps/web/app/api/workspaces/idOrSlug/billing/activate-paid-plan/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/billing/activate-paid-plan/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/webhooks/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/webhooks/page-client.tsx)
- [apps/web/app/ee/api/cron/invoices/retry-failed/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/promotion-code-updated.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts)
- [apps/web/app/api/dub/webhook/lead-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/lead-created.ts)
- [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts)
## Overview
Dub integrates with Stripe to manage enterprise billing, subscription lifecycles, webhook event routing, and automated monetization workflows. The system processes incoming Stripe webhooks with cryptographic signature verification, routing events to specialized handlers that maintain workspace plan capabilities, enforce quota limits, and manage custom invoicing. Adjacent components handle multi-mode Stripe Connect events, partner payout attribution, domain renewal workflows, payment failure mitigations, and automated fraud detection to secure platform monetization.
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:33-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L105), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:36-228](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L36-L228), [apps/web/app/ee/api/stripe/connect/webhook/route.ts:24-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts#L24-L91), [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:9-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts#L9-L94), [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts:9-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts#L9-L158)
## Stripe Webhook Routing Architecture
### Overview
The public Stripe webhook entry point is located at `apps/web/app/(ee)/api/stripe/webhook/route.ts` and wrapped with Axiom telemetry logging via `withAxiom`. The handler reads the raw request body text, extracts the `Stripe-Signature` header, and verifies the payload against the environment variable `STRIPE_WEBHOOK_SECRET` using `stripe.webhooks.constructEvent()`.
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:33-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L42)
> [!WARNING]
> Requests lacking a valid `Stripe-Signature` header or missing `STRIPE_WEBHOOK_SECRET` fail immediately with HTTP status 400 via `logAndRespond("Invalid request", { status: 400 })`.
> Sources: [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L39-L41)
### Event Filtering and Dispatch Routing
Once successfully parsed into a `Stripe.Event` object, the incoming event type is evaluated against a predefined `Set` of eleven `relevantEvents`. If an event type is not contained within this set, the router immediately skips execution and returns a 200 OK response.
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:37-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L37-L55)
The table below details all eleven supported events recognized by `relevantEvents` alongside their corresponding event-handling functions:
| Stripe Event Type | Handler Function | Sources |
| :--- | :--- | :--- |
| `charge.succeeded` | `chargeSucceeded(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L19), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L60-L62) |
| `charge.failed` | `chargeFailed(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L20), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L63-L65) |
| `charge.refunded` | `chargeRefunded(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L21), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L66-L68) |
| `charge.dispute.created` | `chargeDisputeCreated(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L22), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L69-L71) |
| `checkout.session.completed` | `checkoutSessionCompleted(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L23), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L72-L74) |
| `customer.subscription.created` | `customerSubscriptionCreated(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L24), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L75-L77) |
| `customer.subscription.updated` | `customerSubscriptionUpdated(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L25), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L78-L80) |
| `customer.subscription.deleted` | `customerSubscriptionDeleted(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L26), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L81-L83) |
| `invoice.payment_failed` | `invoicePaymentFailed(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L27), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L84-L86) |
| `payment_intent.requires_action` | `paymentIntentRequiresAction(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L28), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L87-L89) |
| `transfer.reversed` | `transferReversed(event)` | [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L30), [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L90-L92) |
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:18-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L30), [apps/web/app/ee/api/stripe/webhook/route.ts:59-93](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L59-L93)
### Error Handling and Execution Flow
If an exception occurs during the execution of any specialized event handler within the `switch` statement, the catch block intercepts the error, writes a structured error entry with `log({ message: ..., type: "errors" })`, and returns a HTTP 400 response containing the error message.
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:94-102](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L94-L102)
When processing succeeds without interruption, the route returns the result formatted via `logAndRespond(...)`.
Sources: [apps/web/app/ee/api/stripe/webhook/route.ts:104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L104)
## Subscription Lifecycle and Plan Downgrades
### Overview
The subscription lifecycle manages the transition of workspaces between plans, tiers, billing intervals, and trial periods via Stripe webhook events. When a workspace completes a subscription checkout, `checkoutSessionCompleted` validates that the session mode is set to `"subscription"` and that the payment status is either `"paid"` or `"no_payment_required"`. It retrieves the subscription object, determines the price identifier and plan mapping using `getPlanAndTierFromPriceId`, extracts workspace limits and billing periods, and updates the workspace project model in Prisma with its stripe customer identifier, billing cycle start day, plan tier, and individual feature limits.
Sources: [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:19-75](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L19-L75), [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:80-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L80-L105)
### Subscription Creation and Checkout Flow
The initialization call-chain executes sequentially upon a successful checkout session completion:
`checkoutSessionCompleted()` → `stripe.subscriptions.retrieve()` → `getPlanAndTierFromPriceId()` → `prisma.project.update()` → `completeOnboarding()` → `onboardingStepCache.mset()` / `createProgram()` / domain registration.
During this sequence, if a workspace transitions from trial status or upgrades, token caches are expired and welcome emails or onboarding tasks are triggered.
```typescript
export async function checkoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
) {
const checkoutSession = event.data.object;
if (checkoutSession.mode !== "subscription") {
return `Session mode not handled, skipping...`;
}
const subscription = await stripe.subscriptions.retrieve(
checkoutSession.subscription as string,
);
const priceId = subscription.items.data[0].price.id;
const { plan, planTier } = getPlanAndTierFromPriceId({ priceId });
return `Checkout completed for workspace, upgraded to ${plan.name}.`;
}
```
Sources: [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:19-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L19-L56), [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:80-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L80-L105)
> [!NOTE]
> Sessions operating in `setup` mode are explicitly skipped by `checkoutSessionCompleted` before retrieving subscription records or updating workspace states.
Sources: [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts:24-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts#L24-L26)
### Subscription Updates and Plan Changes
When subscriptions are modified, `customerSubscriptionUpdated` filters incoming events to accept statuses `"active"`, `"trialing"`, or `"past_due"`. For past-due events, if the trial ended less than 2 hours ago, the workspace reverts to trial limits. Otherwise, updates are delegated to `updateWorkspacePlan`.
| Plan Check / Trigger | Condition Evaluated | Action Taken | Sources |
| :--- | :--- | :--- | :--- |
| **Non-Hardcoded Plan** | `!newPlan` | Updates `billingCycleEndsAt` and `planPeriod` without standard tier limits. | [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L82-L96) |
| **Yearly to Monthly** | `workspace.planPeriod === "yearly" && planPeriod === "monthly"` | Recomputes workspace usage and clears limit warning emails. | [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L108-L113) |
| **Partner Limit Upgrade** | `workspace.partnersLimit < newPlan.limits.partners && NEW_BUSINESS_PRICE_IDS.includes(priceId)` | Triggers workspace plan, tier, and limit updates in Prisma. | [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L120-L126) |
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-updated.ts:8-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts#L8-L89), [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:34-171](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L34-L171)
> [!WARNING]
> If a workspace changes plan capabilities such that `canCreateWebhooks` becomes false, associated webhooks are immediately disabled in the database and `webhookEnabled` is set to false.
Sources: [apps/web/app/ee/api/stripe/webhook/utils/update-workspace-plan.ts:220-226](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts#L220-L226)
### Subscription Cancellations and Feature Pruning
When a subscription is deleted via `customerSubscriptionDeleted`, the system checks for alternative active or trialing subscriptions. If a fallback subscription exists, `updateWorkspacePlan` applies it. If no active subscriptions remain, the workspace is downgraded to the `"free"` plan, and strict quota recomputations and feature pruning occur.
```mermaid
graph TD
A["customer.subscription.deleted"] --> B["Active fallback subscription?"]
B -- Yes --> C["Update workspace to fallback plan"]
B -- No --> D["Downgrade workspace to free plan"]
D --> E["Reset limits to FREE_PLAN constants"]
D --> F["Remove root domain links & domain logos"]
D --> G["Disable webhooks & expire token caches"]
D --> H["Delete workspace folders & deactivate partner program"]
H --> I["Would lose advanced features?"]
I -- Yes --> J["Strip advanced reward modifiers & pause campaigns"]
J --> K["Send AdvancedPlanDowngradeNotice email"]
```
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:23-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L23-L100), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:105-267](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L105-L267)
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| **Fallback Subscription Check** | Prevents accidental immediate downgrades when a customer replaces a subscription. | Requires extra Stripe API list calls for active and trialing statuses. | [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L74-L100) |
| **Immediate Feature Pruning** | Enforces tier boundaries instantly upon cancellation. | Disables integrations (webhooks, domains) immediately upon termination. | [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L133-L211) |
| **Invoice Voiding on Deletion** | Prevents open or uncollectible invoices from remaining payable after cancellation. | Modifies external Stripe invoice states asynchronously. | [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L210-L210), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L270-L298) |
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:74-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L74-L100), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:133-211](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L133-L211), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:210-210](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L210-L210), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:270-298](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L270-L298)
> [!CAUTION]
> Deleting a subscription triggers `voidLatestInvoiceIfPayable`, which checks if the latest invoice status is `"open"` or `"uncollectible"` and forcefully voids it via Stripe to prevent billing errors on cancelled accounts.
Sources: [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:210-210](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L210-L210), [apps/web/app/ee/api/stripe/webhook/customer-subscription-deleted.ts:270-298](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts#L270-L298)
## Charge Events and Domain Billing
### Overview
The `charge.succeeded` Stripe webhook event is handled by `chargeSucceeded`, which coordinates charge validation, invoice completion, and domain registration or payout fulfillment. When a charge succeeds, the event payload exposes a `transfer_group` that maps directly to an internal invoice identifier. If no `transfer_group` is present, the webhook checks whether the customer's workspace has an active `paymentFailedAt` timestamp and resets it to `null` before skipping further invoice processing.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:12-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L12-L39), [apps/web/app/ee/api/stripe/webhook/route.ts:60-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L60-L62)
When an `invoiceId` is found via `transfer_group`, the database is queried for the corresponding invoice. If the invoice is already marked as `"completed"`, processing is short-circuited. Otherwise, the invoice record is updated with the charge's receipt URL, completed status, payment timestamp, and serialized charge metadata. Depending on the invoice type, the system branches into partner payout processing or domain renewal workflows.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:41-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L41-L74)
### Call-Chain Execution Walkthroughs
#### Partner Payout Execution Flow
1. `POST` receives the raw webhook request body and Stripe signature header, constructs the event via `stripe.webhooks.constructEvent`, and routes the `"charge.succeeded"` type to `chargeSucceeded`.
2. `chargeSucceeded` reads `charge.transfer_group` as `invoiceId`, retrieves and updates the `Invoice` record to status `"completed"`, and inspects `invoice.type`. Because the type equals `"partnerPayout"`, it hands control to `processPayoutInvoice`.
3. `processPayoutInvoice` counts incomplete payouts linked to the invoice ID and, if any exist, publishes a JSON payload to QStash via `qstash.publishJSON` targeting the endpoint `/api/cron/payouts/charge-succeeded` with flow control parallelism set to `1`.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:12-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L12-L68), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:76-107](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L76-L107), [apps/web/app/ee/api/stripe/webhook/route.ts:33-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L62)
#### Domain Renewal Execution Flow
1. `POST` receives and verifies the Stripe webhook event, dispatching `"charge.succeeded"` to `chargeSucceeded`.
2. `chargeSucceeded` updates the matching invoice and evaluates `invoice.type === "domainRenewal"`, which invokes `processDomainRenewalInvoice`.
3. `processDomainRenewalInvoice` parses registered domain slugs using `parseRegisteredDomainSlugs` and checks if it represents a registration invoice via `isDomainRegistrationInvoice`. If true, it finalizes the registration via `finalizePremiumDomainRegistration`. Otherwise, it queries `prisma.registeredDomain` and dispatches a renewal success message to QStash targeting `/api/cron/domains/renewal-succeeded`.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:12-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L12-L74), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:109-152](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L109-L152), [apps/web/app/ee/api/stripe/webhook/route.ts:33-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L62)
```mermaid
sequenceDiagram
participant Route as route.ts
participant CS as charge-succeeded.ts
participant DB as Prisma DB
participant QS as QStash / Cron
Route->>CS: chargeSucceeded(event)
CS->>DB: prisma.invoice.findUnique(invoiceId)
DB-->>CS: return invoice
CS->>DB: prisma.invoice.update(status: completed)
alt invoice.type === partnerPayout
CS->>CS: processPayoutInvoice()
CS->>DB: prisma.payout.count()
CS->>QS: qstash.publishJSON(payouts endpoint)
else invoice.type === domainRenewal
CS->>CS: processDomainRenewalInvoice()
CS->>DB: isDomainRegistrationInvoice / findMany
CS->>QS: qstash.publishJSON(renewal endpoint)
end
QS-->>CS: return messageId response
```
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:12-152](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L12-L152), [apps/web/app/ee/api/stripe/webhook/route.ts:60-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L60-L62)
### Invoice Processing and Domain Operations
| Invoice Type / Helper | Target Function / Action | QStash Endpoint / Target | Sources |
| :--- | :--- | :--- | :--- |
| **`partnerPayout`** | `processPayoutInvoice` | `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded` | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L67-L68), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L90-L91) |
| **`domainRenewal` (Registration)** | `finalizePremiumDomainRegistration` | Direct function execution (`slugs[0]`) | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L69-L70), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L118-L121) |
| **`domainRenewal` (Standard)** | `processDomainRenewalInvoice` | `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/renewal-succeeded` | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L69-L70), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L140-L141) |
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:67-71](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L67-L71), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:90-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L90-L91), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:118-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L118-L121), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:140-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L140-L141)
> [!NOTE]
> When a successful charge lacks a `transfer_group` but includes a Stripe customer ID, the system queries the project table for a matching `stripeId`. If `paymentFailedAt` is populated on that workspace, it is automatically reset to `null` to clear previous billing failure states.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:17-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L17-L37)
### Design Trade-Offs in Charge Processing
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| **Transfer Group as Invoice ID** | Links incoming Stripe charge events directly to internal invoice primary keys without metadata lookups. | Relies strictly on Stripe transfer group population during charge creation. | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L15-L15) |
| **QStash Job Offloading** | Asynchronously decouples downstream payout and domain renewal execution from the webhook response cycle. | Introduces external queue dependency and potential delay in asynchronous fulfillment. | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L90-L90), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L139-L139) |
| **Idempotent Status Checks** | Prevents duplicate processing if Stripe retries the `charge.succeeded` webhook event. | Requires maintaining explicit status checks on the database invoice record. | [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L51-L53) |
Sources: [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:15-15](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L15-L15), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:51-53](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L51-L53), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:90-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L90-L90), [apps/web/app/ee/api/stripe/webhook/charge-succeeded.ts:139-139](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts#L139-L139)
## Payment Failures and Fraud Prevention
### Overview
Payment failures and fraudulent charges are handled through dedicated webhook handlers, automated cron retry routines, and proactive edge blocklist enforcement. When an invoice payment fails (`invoice.payment_failed`), the system retrieves the workspace via `stripeId`, updates `paymentFailedAt` to the current timestamp, and sends branded notification emails through `@dub/email` using the `FailedPayment` template. The email subject line dynamically adjusts based on the attempt count (1st notice, 2nd notice, 3rd notice, or Final notice).
Sources: [apps/web/app/ee/api/stripe/webhook/invoice-payment-failed.tsx:6-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx#L6-L89)
For domain renewal invoices that fail, a dedicated cron route (`POST /api/cron/invoices/retry-failed`) parses the `invoiceId` using Zod, validates that the invoice status is `"failed"`, checks that `failedAttempts` is strictly less than 3, and ensures the invoice type is specifically `"domainRenewal"`. If an Acme workspace is involved, it resolves the associated Dub workspace Stripe ID before invoking `createPaymentIntent` with an idempotency key combining the invoice ID and attempt count.
Sources: [apps/web/app/ee/api/cron/invoices/retry-failed/route.ts:13-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts#L13-L91)
### Fraud Detection and Charge Disputes
When a charge dispute is created (`charge_dispute.created`), the system retrieves the associated charge and workspace, disables workspace links, downgrades all users except `LEGAL_USER_ID` to the `"viewer"` role, upserts `LEGAL_USER_ID` as the workspace owner, marks the project as disabled, adds customer details to Stripe fraud value lists, and cancels the subscription.
Sources: [apps/web/app/ee/api/stripe/webhook/charge-dispute-created.ts:9-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/charge-dispute-created.ts#L9-L94)
Similarly, failed charges with an outcome risk level of `"highest"` trigger `detectAndHandleFraudulentFailedCharge`. If no project exists for the customer ID, the customer and card fingerprint are added to Stripe fraud value lists, and any free workspaces owned by the user are either deleted (if they have no links) or disabled with ownership transferred to `LEGAL_USER_ID`. If the user has no paid workspaces, their account is deleted and their email is added to the edge configuration blocklist.
Sources: [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts:9-157](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts#L9-L157)
> [!WARNING]
> During fraudulent charge handling, if a workspace associated with a fraudulent user is found to be on a paid plan (`workspace.plan !== "free"`), the system skips automated workspace deletion or link disabling, logs an error, and prevents the user deletion step if any paid workspaces remain.
Sources: [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts:71-79](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts#L71-L79), [apps/web/app/ee/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts:131-131](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/utils/detect-and-handle-fraudulent-failed-charge.ts#L131-L131)
## Partner Commissions and Integration Webhooks
### Overview
Partner commission attribution and integration webhook routing manage external partner revenue sharing, promotional code updates, and connected Stripe account synchronization. The primary integration webhook endpoint (`POST /api/stripe/integration/webhook`) accepts signed Stripe events across live, test, and sandbox modes, extracting headers via `Stripe-Signature` and verifying payloads using workspace-specific webhook secrets.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/route.ts:35-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L35-L55)
The integration dispatcher filters events against a strict set of relevant event types before resolving the target workspace via `prisma.project.findUnique` using the `stripeConnectId` present on the event account.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/route.ts:22-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:107-119](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L107-L119)
### Integration Event Routing
| Event Type | Handler / Action | Target Workspace Resolution | Sources |
| :--- | :--- | :--- | :--- |
| `account.application.deauthorized` | `accountApplicationDeauthorized` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L133-L139) |
| `charge.refunded` | `chargeRefunded` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L140-L146) |
| `checkout.session.completed` | `checkoutSessionCompleted` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L147-L153) |
| `coupon.deleted` | `couponDeleted` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L154-L159) |
| `customer.created` / `customer.updated` | `syncCustomer` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L160-L166) |
| `customer.subscription.created` | `customerSubscriptionCreated` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L167-L173) |
| `customer.subscription.deleted` | `customerSubscriptionDeleted` | Event payload extraction | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L174-L176) |
| `invoice.paid` | `invoicePaid` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L177-L183) |
| `promotion_code.updated` | `promotionCodeUpdated` | `prisma.project.findUnique({ where: { stripeConnectId: event.account } })` | [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L184-L189) |
Sources: [apps/web/app/ee/api/stripe/integration/webhook/route.ts:22-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:133-190](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L133-L190)
### Invoice Paid Attribution and Processing
When an `invoice.paid` event arrives, `invoicePaid` performs multi-tier customer resolution and attribution. It first queries the `Customer` table by `stripeCustomerId`. If not found, it retrieves the connected customer from Stripe, extracts `dubCustomerExternalId` from metadata, and updates the customer record. If still unresolved, it attempts partner promotion code attribution via the invoice.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts:27-119](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts#L27-L119)
> [!NOTE]
> If an invoice sale amount is less than or equal to zero, or if the invoice ID has already been logged within Upstash Redis using a 7-day TTL, the event processing is safely skipped to prevent duplicate commission attribution.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts:128-163](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts#L128-L163)
### Promotion Code Updates
The `promotionCodeUpdated` handler manages promotional discounts linked to Dub partner programs. When a Stripe promotion code update event occurs, if the promotion code becomes inactive, the system queries `prisma.discountCode` for the matching code within the workspace's `defaultProgramId` and deletes the discount code record.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/promotion-code-updated.ts:6-53](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts#L6-L53)
## Related
- [[Payout Processing]]
- [[Conversion and Event Tracking]]
---
## Technical docs: GET Get trusted partners
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/getadmintrustedpartners
## Responses
## Try It
---
## Technical docs: Stripe Marketplace App
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/stripe-marketplace-app
Relevant source files
The following files were used as context for generating this wiki page:
- [packages/stripe-app/stripe-app.json](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [packages/hubspot-app/src/app/app-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/integrations/integrationSlug/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/%5BintegrationSlug%5D/page-client.tsx)
- [packages/stripe-app/src/views/AppSettings.tsx](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx)
- [packages/stripe-app/src/utils/constants.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts)
- [apps/web/ui/layout/sidebar/app-sidebar-nav.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/analytics/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/analytics/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/components/user-info.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/earnings-summary.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/earnings-summary.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/analytics/program-analytics-shell.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/analytics/program-analytics-shell.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/page.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/activity.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx)
- [packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json)
- [apps/web/app/ee/app.dub.co/embed/referrals/resources.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/resources.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/billing/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/billing/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/earnings/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/earnings/page.tsx)
- [packages/stripe-app/src/hooks/use-workspace.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/installation-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/installation-section.tsx)
- [apps/web/ui/guides/install-stripe-integration-button.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/guides/install-stripe-integration-button.tsx)
- [apps/web/app/app.dub.co/marketplace/...segments/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/%5B%5B...segments%5D%5D/page.tsx)
- [apps/web/app/ee/partners.dub.co/dashboard/programs/programSlug/enrolled/events/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/%5BprogramSlug%5D/(enrolled)/events/page.tsx)
- [apps/web/ui/analytics/index.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/analytics/index.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/programs/marketplace/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/programs/marketplace/page.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/token.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/token.tsx)
- [apps/web/app/app.dub.co/marketplace/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/marketplace/layout.tsx)
## Overview
The Stripe Marketplace App integration bridges Stripe payments and Dub's partner program infrastructure, automating conversion tracking and partner commission generation. By packaging extension definitions, secure OAuth authentication flows, workspace context hooks, and native settings controls into a unified Stripe App manifest, it enables merchants to seamlessly connect their Stripe accounts with Dub workspaces. This integration solves the complexity of attribution across payment boundaries, ensuring that checkout events, invoices, and subscriptions correctly trigger affiliate rewards without manual reconciliation. Sources: [packages/stripe-app/stripe-app.json:1-90](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L1-L90), [apps/web/scripts/dev/data.json:277-297](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json#L277-L297), [packages/stripe-app/src/views/AppSettings.tsx:1-200](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L1-L200)
## Package Architecture and App Manifest
### Overview
The Stripe App manifest configuration defines the structural integration between the Dub Partners application and the Stripe Marketplace ecosystem. Controlled by `packages/stripe-app/stripe-app.json` against the official Stripe App schema, the application identifies as `dub.co` under the display name `Dub Partners` at version `0.0.25`. Distribution is configured as public with sandbox installation compatibility enabled, utilizing OAuth as the Stripe API access type. Sources: [packages/stripe-app/stripe-app.json:1-91](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L1-L91)
### UI Extension and SDK View Declarations
The UI extension block establishes the view mount points and security policies required for rendering settings controls inside the Stripe dashboard. A single view is declared for the `settings` viewport, loading the `AppSettings` component. Content security policy settings explicitly whitelist OAuth and integration endpoints for connection requests. Sources: [packages/stripe-app/stripe-app.json:7-21](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L7-L21)
| Viewport | Component | Purpose | Sources |
| :--- | :--- | :--- | :--- |
| `settings` | `AppSettings` | Renders configuration controls within the Stripe settings viewport | [packages/stripe-app/stripe-app.json:9-12](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L9-L12) |
> [!NOTE]
> The content security policy restricts `connect-src` directives strictly to `https://api.dub.co/oauth/` and `https://api.dub.co/stripe/integration`, ensuring all external API calls remain bound to authorized Dub domains. Sources: [packages/stripe-app/stripe-app.json:14-20](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L14-L20)
### Host Constants and Permissions Schema
The manifest requires an extensive permissions array covering customer, subscription, invoice, charge, checkout session, user email, connected account, webhook, event, secret, coupon, and promotion code scopes. Alongside these permissions, the app configures allowed redirect URIs and post-install actions. Client configuration constants establish connection endpoints and the Dub client identifier used across runtime integrations. Sources: [packages/stripe-app/stripe-app.json:23-90](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L23-L90), [packages/stripe-app/src/utils/constants.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L1-L6)
| Constant Name | Value | Purpose | Sources |
| :--- | :--- | :--- | :--- |
| `DUB_CLIENT_ID` | `dub_app_517290377fe6b4dfcc8726a7061ba9b6da1c4d7d7d75f77a` | Unique Dub application client identifier | [packages/stripe-app/src/utils/constants.ts:2-3](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L2-L3) |
| `DUB_HOST` | `https://app.dub.co` | Primary Dub web application host URL | [packages/stripe-app/src/utils/constants.ts:4`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L4) |
| `DUB_API_HOST` | `https://api.dub.co` | Core Dub API server base URL | [packages/stripe-app/src/utils/constants.ts:5`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L5) |
> [!CAUTION]
> Modifying `allowed_redirect_uris` or altering the `connect-src` CSP block without updating the corresponding backend routes at `https://app.dub.co/api/stripe/integration/callback` will break the OAuth authorization exchange. Sources: [packages/stripe-app/stripe-app.json:14-20](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L14-L20), [packages/stripe-app/stripe-app.json:85-87](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/stripe-app.json#L85-L87)
## OAuth Handshake and Authentication Lifecycle
### Overview
The OAuth handshake and authentication lifecycle manages user authorization between the Stripe App and the Dub platform. This subsystem initializes cryptographic state parameters, exchanges authorization codes for authentication tokens, and validates active sessions against the Dub API. Sources: [packages/stripe-app/src/views/AppSettings.tsx:24-142](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L24-L142)
### Connection Call-Chain Execution Walkthrough
When an unauthenticated user loads the application settings view without an active workspace, the runtime executes a precise initialization and authentication sequence. Sources: [packages/stripe-app/src/views/AppSettings.tsx:121-141](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L121-L141)
1. `useEffect` evaluates the initial component state: if `workspace` is absent and no `code` or `verifier` parameters are present in the `oauthContext`, it invokes `createOAuthState()` from `@stripe/ui-extension-sdk/oauth`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:121-139](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L121-L139)
2. `createOAuthState()` resolves with an `state` string and a cryptographic `challenge`, which are stored via `setOAuthState` and `setChallenge`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:30-31](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L30-31), [packages/stripe-app/src/views/AppSettings.tsx:135-138](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L135-L138)
3. The user interacts with the `SignInView` component, whose primary action triggers `getOAuthUrl({ state, challenge, mode })` using the generated parameters. Sources: [packages/stripe-app/src/views/AppSettings.tsx:170-183](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L170-L183)
4. Upon completing the remote sign-in flow, the extension receives `oauthContext?.code` and `oauthContext?.verifier`, triggering the `connectWorkspace()` function. Sources: [packages/stripe-app/src/views/AppSettings.tsx:36-37](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L36-L37), [packages/stripe-app/src/views/AppSettings.tsx:127-131](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L127-L131)
5. `connectWorkspace()` calls `getToken({ code, verifier, mode })` to exchange the authorization code for an access token. Sources: [packages/stripe-app/src/views/AppSettings.tsx:82-86](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L82-L86)
6. The resulting token payload is serialized and stored via `setSecret({ stripe, name: "dub_token", payload })`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:92-96](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L92-L96)
7. `getUserInfo({ token })` fetches associated workspace details, which are then stored via `setSecret({ stripe, name: "dub_workspace", payload })` after calling `updateWorkspace()`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:98-114](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L98-L114)
> [!WARNING]
> If `code` or `verifier` are missing when the effect hook fires, `connectWorkspace()` exits immediately without executing token exchange, halting the handshake. Sources: [packages/stripe-app/src/views/AppSettings.tsx:78-80](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L78-80)
### Host Endpoints and Client Configuration
The authentication flow relies on predefined constants pointing to the Dub platform infrastructure. Sources: [packages/stripe-app/src/utils/constants.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L1-L6)
| Constant Identifier | Value | Description | Sources |
| :--- | :--- | :--- | :--- |
| `DUB_CLIENT_ID` | `dub_app_517290377fe6b4dfcc8726a7061ba9b6da1c4d7d7d75f77a` | Unique application identifier for Dub OAuth requests | [packages/stripe-app/src/utils/constants.ts:2-3](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L2-L3) |
| `DUB_HOST` | `https://app.dub.co` | Base URL for Dub web application and authentication views | [packages/stripe-app/src/utils/constants.ts:4`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L4) |
| `DUB_API_HOST` | `https://api.dub.co` | Base URL for Dub REST API interactions | [packages/stripe-app/src/utils/constants.ts:5`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/constants.ts#L5) |
> [!TIP]
> The `credentialsUsed` ref prevents duplicate execution of `connectWorkspace()` if component re-renders coincide with an active OAuth callback lifecycle. Sources: [packages/stripe-app/src/views/AppSettings.tsx:29`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L29), [packages/stripe-app/src/views/AppSettings.tsx:117`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L117), [packages/stripe-app/src/views/AppSettings.tsx:128`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L128)
## Workspace State and Context Hooks
### Workspace State and Context Hooks
### Overview
The `useWorkspace` hook manages workspace retrieval and synchronization within the Stripe UI extension context. It accepts a `Stripe` instance, maintains local loading and workspace states via React hooks, and exposes a mutation handle to reload workspace data on demand. Sources: [packages/stripe-app/src/hooks/use-workspace.ts:7-27](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts#L7-L27)
```typescript
export const useWorkspace = (stripe: Stripe) => {
const [workspace, setWorkspace] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const loadWorkspace = useCallback(async () => {
setIsLoading(true);
const fetchedWorkspace = await fetchWorkspace({ stripe });
setWorkspace(fetchedWorkspace);
setIsLoading(false);
}, [stripe]);
useEffect(() => {
loadWorkspace();
}, [loadWorkspace]);
return {
workspace,
isLoading,
mutate: loadWorkspace,
};
};
```
Sources: [packages/stripe-app/src/hooks/use-workspace.ts:7-27](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts#L7-L27)
### Secret Storage and Workspace Retrieval
Workspace persistence relies on Stripe's secret storage layer. The internal `fetchWorkspace` helper queries secret storage for the `dub_workspace` entry using the provided `stripe` client instance, returning the deserialized `Workspace` object or `null` if unconfigured. Sources: [packages/stripe-app/src/hooks/use-workspace.ts:29-37](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts#L29-L37)
```typescript
async function fetchWorkspace({ stripe }: { stripe: Stripe }) {
const workspace = await getSecret({
stripe,
name: "dub_workspace",
});
return workspace;
}
```
Sources: [packages/stripe-app/src/hooks/use-workspace.ts:29-37](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts#L29-L37)
> [!NOTE]
> The `useWorkspace` hook automatically triggers `loadWorkspace()` on initial component mount through a `useEffect` hook wrapped around `useCallback`. Sources: [packages/stripe-app/src/hooks/use-workspace.ts:11-20](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/hooks/use-workspace.ts#L11-L20)
### Stripe SDK Context Access
Views such as `AppSettings` receive extension context via the `ExtensionContextValue` interface provided by `@stripe/ui-extension-sdk/context`. This context supplies runtime properties including `userContext`, `oauthContext`, and `environment` configuration, alongside the initialized `stripe` SDK utility. Sources: [packages/stripe-app/src/views/AppSettings.tsx:1-28](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L1-L28)
| Context Property | Type / Source | Purpose | Sources |
| :--- | :--- | :--- | :--- |
| `userContext` | `ExtensionContextValue['userContext']` | Provides active Stripe account metadata, including sandbox status (`isSandbox`) and account ID (`id`) | [packages/stripe-app/src/views/AppSettings.tsx:25`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L25), [packages/stripe-app/src/views/AppSettings.tsx:61-63](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L61-L63), [packages/stripe-app/src/views/AppSettings.tsx:106-107](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L106-L107) |
| `oauthContext` | `ExtensionContextValue['oauthContext']` | Carries authorization callback parameters (`code` and `verifier`) returning from the OAuth flow | [packages/stripe-app/src/views/AppSettings.tsx:26`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L26), [packages/stripe-app/src/views/AppSettings.tsx:36-37](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L36-L37) |
| `environment` | `ExtensionContextValue['environment']` | Exposes current execution mode (`mode`) for API and authentication routing | [packages/stripe-app/src/views/AppSettings.tsx:27`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L27), [packages/stripe-app/src/views/AppSettings.tsx:63`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L63), [packages/stripe-app/src/views/AppSettings.tsx:85`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L85) |
## App Settings and Connection Controls
### Overview
The `AppSettings` component manages the extension settings user interface, governing workspace connectivity, OAuth callback handling, and credentials lifecycle. Built with Stripe UI extension components such as `SignInView`, `Banner`, `Box`, and `Spinner`, the view dynamically renders either an active workspace banner with a destructive disconnect control or a sign-in view prompting users to link their Dub account. Sources: [packages/stripe-app/src/views/AppSettings.tsx:4-10](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L4-L10), [packages/stripe-app/src/views/AppSettings.tsx:24-35](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L24-L35), [packages/stripe-app/src/views/AppSettings.tsx:147-195](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L147-L195)
> [!NOTE]
> When `isLoading` or `connecting` is true, `AppSettings` immediately renders a centered `Spinner` component with a `large` size, blocking UI interaction during asynchronous token exchanges and workspace lookups. Sources: [packages/stripe-app/src/views/AppSettings.tsx:143-145](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L143-L145)
### Connection Call-Chain Execution
When an authorization code returns from the OAuth redirect, the component automatically executes `connectWorkspace()` inside a `useEffect` hook. The exact call chain proceeds as follows:
1. `getToken()` — Exchanges the authorization code and verifier for an API token under the active environment mode. Sources: [packages/stripe-app/src/views/AppSettings.tsx:82-86](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L82-L86)
2. `setSecret()` — Persists the serialized token payload into Stripe secret storage under the name `dub_token`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:92-96](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L92-L96)
3. `getUserInfo()` — Queries Dub workspace metadata using the acquired token. Sources: [packages/stripe-app/src/views/AppSettings.tsx:98`](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L98)
4. `updateWorkspace()` — Links the Dub account to the Stripe account ID, resolving stripe mode between sandbox and live execution based on `userContext.account.isSandbox`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:104-108](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L104-L108)
5. `setSecret()` — Stores the retrieved workspace metadata in secret storage under `dub_workspace`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:110-114](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L110-L114)
6. `mutate()` — Refreshes the local workspace hook state, concluding with `credentialsUsed.current = true`. Sources: [packages/stripe-app/src/views/AppSettings.tsx:116-117](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L116-L117)
```typescript
const connectWorkspace = async () => {
setConnecting(true);
if (!code || !verifier) {
return;
}
const token = await getToken({
code,
verifier,
mode: environment.mode,
});
if (!token) {
return;
}
await setSecret({
stripe,
name: "dub_token",
payload: JSON.stringify(token),
});
const workspace = await getUserInfo({ token });
if (!workspace) {
return;
}
await updateWorkspace({
token,
accountId: userContext.account.id,
stripeMode: userContext.account.isSandbox ? "sandbox" : environment.mode,
});
await setSecret({
stripe,
name: "dub_workspace",
payload: JSON.stringify(workspace),
});
await mutate();
credentialsUsed.current = true;
setConnecting(false);
};
```
Sources: [packages/stripe-app/src/views/AppSettings.tsx:75-119](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L75-L119)
### Disconnection and Cleanup
Disconnecting a workspace reverses the linkage via the `disconnectWorkspace` routine. It invokes `getValidToken()` to authenticate the removal request, deletes both secret keys (`dub_workspace` and `dub_token`) in parallel using `Promise.all()`, updates the remote workspace with a `null` account ID, and triggers a state mutation. Sources: [packages/stripe-app/src/views/AppSettings.tsx:40-69](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L40-L69)
| Action Function | Target Keys / Parameters | Execution Behavior | Sources |
| :--- | :--- | :--- | :--- |
| `disconnectWorkspace` | `dub_workspace`, `dub_token` | Deletes stored secrets, disassociates the workspace account ID via API, and resets local workspace state | [packages/stripe-app/src/views/AppSettings.tsx:40-69](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L40-L69) |
| `connectWorkspace` | `dub_token`, `dub_workspace` | Exchanges OAuth code for token, stores credentials, registers account mapping, and updates workspace state | [packages/stripe-app/src/views/AppSettings.tsx:75-119](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L75-L119) |
> [!WARNING]
> The `useEffect` dependency array monitors `[workspace, oauthState, code, verifier]`. If `code` and `verifier` are present without an existing workspace and `credentialsUsed.current` is false, it executes `connectWorkspace()` exactly once, preventing duplicate authorization code exchanges. Sources: [packages/stripe-app/src/views/AppSettings.tsx:121-141](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx#L121-L141)
## Dashboard Integration Settings and Installation
### Overview
The Dub web dashboard provides integration management interfaces and UI triggers for installing the Stripe marketplace app. Within a workspace's settings, the dashboard maps registered integrations to their respective settings components, utilizing `STRIPE_INTEGRATION_ID` to render Stripe-specific UI elements. Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:67-75](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/%5BintegrationSlug%5D/page-client.tsx#L67-L75)
### Connection State Banners
For installed Stripe integrations, the client evaluates settings against `stripeIntegrationSettingsSchema` to determine the execution mode. Based on the parsed mode, it configures visual status banners distinguishing between live, test, and sandbox environments. Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:123-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/%5BintegrationSlug%5D/page-client.tsx#L123-L158)
| Stripe Mode | Background Style | Caption Style | Icon Indicator | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `live` | `bg-emerald-50` | `text-emerald-900` | `CheckCircleFill` (emerald) | [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:135-140](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/%5BintegrationSlug%5D/page-client.tsx#L135-L140) |
| `test` | `bg-[#F0F7FF]` | `text-blue-900/80` | `Flask` (blue) | [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:141-147](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/[integrationSlug]/page-client.tsx#L141-L147) |
| `sandbox` | `bg-orange-50` | `text-orange-900/80` | `Flask` (orange) | [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:148-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/[integrationSlug]/page-client.tsx#L148-L155) |
> [!NOTE]
> The uninstallation link dynamically adjusts its URL based on the active Stripe mode, directing users to the appropriate test or live app installation management path in the Stripe Dashboard. Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx:160-164](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx#L160-L164)
### Installation Flow Triggers
The installation trigger uses `InstallStripeIntegrationButton` to poll active workspace integrations and manage installation state transitions. If the Stripe integration is not yet active, it renders a call-to-action button linking to the workspace integration settings page while polling state every 1000ms to 5000ms. Sources: [apps/web/ui/guides/install-stripe-integration-button.tsx:13-31](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/guides/install-stripe-integration-button.tsx#L13-31), [apps/web/ui/guides/install-stripe-integration-button.tsx:52-71](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/guides/install-stripe-integration-button.tsx#L52-71)
## Related
- [[Stripe Billing and Webhooks]]
- [[OAuth2 Provider and API Tokens]]
---
## Technical docs: POST Mark partner as trusted
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/addadmintrustedpartner
## Request Body
Partner ID or email
## Responses
## Try It
---
## Technical docs: DELETE Remove trusted partner status
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-partners/removeadmintrustedpartner
## Request Body
Partner ID
## Responses
## Try It
---
## Technical docs: HubSpot Integration
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/hubspot-integration
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/hubspot/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts)
- [apps/web/lib/integrations/hubspot/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/app/ee/api/hubspot/webhook/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts)
- [apps/web/lib/integrations/hubspot/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/app/ee/api/cron/import/partnerstack/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts)
- [apps/web/lib/integrations/hubspot/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/ee/api/cron/framer/backfill-leads-batch/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/api/oauth/authorize/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts)
- [apps/web/app/ee/api/shopify/integration/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts)
- [apps/web/app/ee/api/cron/import/firstpromoter/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/slack/callback/route.ts)
- [apps/web/lib/integrations/hubspot/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts)
- [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts)
- [apps/web/lib/integrations/hubspot/ui/settings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/ui/settings.tsx)
- [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json)
- [packages/stripe-app/src/views/AppSettings.tsx](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx)
- [packages/stripe-app/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/integrations/hubspot/schema.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts)
- [apps/web/lib/integrations/shopify/create-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts)
- [packages/hubspot-app/src/app/app-hsmeta.json](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json)
- [apps/web/app/api/oauth/token/exchange-code-for-token.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
## Overview
The HubSpot integration bridges customer relationship management with link attribution, enabling automated conversion tracking and partner commission generation. By synchronizing CRM contacts and deals with click metadata, workspaces can attribute leads and revenue directly to referral sources.
Sources: [packages/hubspot-app/src/app/app-hsmeta.json:5-6](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json#L5-L6)
## OAuth Handshake and Installation
### OAuth Handshake and Installation
The HubSpot integration initiates its connection workflow through an OAuth 2.0 authorization process configured via the `hubSpotOAuthProvider` instance. During local development, incoming requests outside of localhost automatically redirect through `http://localhost:8888/api/hubspot/callback`.
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:20-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L20-L28), [apps/web/lib/integrations/hubspot/oauth.ts:100-118](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L100-L118)
### Authorization Configuration and Scopes
The provider establishes connection parameters using specific endpoints and required scopes defined in both the server configuration and the application metadata file.
| Configuration Parameter | Value / Endpoint |
| :--- | :--- |
| Provider Name | `HubSpot` |
| Auth URL | `https://app.hubspot.com/oauth/authorize` |
| Token URL | `https://api.hubapi.com/oauth/v1/token` |
| Redirect URI | `${APP_DOMAIN_WITH_NGROK}/api/hubspot/callback` |
| Redis State Prefix | `hubspot:oauth:state` |
| Required Scopes | `oauth`, `crm.objects.contacts.read`, `crm.objects.contacts.write`, `crm.objects.deals.read`, `crm.schemas.contacts.write` |
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:100-118](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L100-L118), [packages/hubspot-app/src/app/app-hsmeta.json:14-20](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/app-hsmeta.json#L14-L20)
### Callback Execution Walkthrough
When HubSpot redirects back to Dub, the callback route processes the authorization grant and establishes the workspace integration through a strict sequence of validation and persistence steps:
1. `getSession()` verifies the active user session; if no valid user ID is present, a `DubApiError` with code `unauthorized` is thrown.
2. `hubSpotOAuthProvider.exchangeCodeForToken(req)` exchanges the authorization code for an OAuth token and workspace context ID (`workspaceId`).
3. `prisma.project.findUniqueOrThrow()` queries the workspace and validates that the current user is a member (`workspace.users.length === 0` throws `bad_request`) and holds an `owner` role (`workspace.users[0].role !== "owner"` throws `bad_request`).
4. `prisma.integration.findUniqueOrThrow()` fetches the integration record for the `hubspot` slug.
5. `encrypt()` secures both the `access_token` and `refresh_token` fields before storing them alongside `created_at: Date.now()`.
6. `installIntegration()` saves the installed integration configuration to the database using the encrypted credentials.
7. `waitUntil()` dispatches an asynchronous batch creation of contact properties (`HUBSPOT_DUB_CONTACT_PROPERTIES`) using a new `HubSpotApi` instance initialized with the raw access token.
8. `redirect()` sends the user to `/${workspace.slug}/settings/integrations/hubspot`.
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:31-118](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L31-L118)
> [!WARNING]
> Only workspace members possessing the `owner` role are permitted to install the HubSpot integration. Attempts to install by non-owners result in a `bad_request` API error.
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:70-76](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L70-L76)
### Token Validation and Refresh Lifecycle
The `HubSpotOAuthProvider` class manages token longevity and validity checks through dedicated helper methods. A token is evaluated using `isTokenValid()`, which incorporates a 60-second early buffer (`60 * 1000` ms) prior to actual expiration.
```typescript
isTokenValid(token: HubSpotAuthToken) {
if (!token.created_at) {
return false;
}
const buffer = 60 * 1000; // refresh 1 min early
const expiresAt = token.created_at + token.expires_in * 1000;
return Date.now() < expiresAt - buffer;
}
```
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:88-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L88-L97)
When an installation requires a refreshed token via `refreshTokenForInstallation()`, the provider parses existing credentials, decrypts them with `decryptOrPassthrough()`, and if validation fails, fetches a new token, re-encrypts the resulting access and refresh tokens, and updates the database record via `prisma.installedIntegration.update()`.
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)
## Contact Property Synchronization
### Contact Property Synchronization
### Overview
Following a successful OAuth installation, Dub initializes tracking properties in HubSpot so that click IDs, short links, and partner attribution emails flow seamlessly into CRM contact records. The `HubSpotApi` client handles communication with HubSpot's v3 CRM API (`https://api.hubapi.com/crm/v3`), managing batch custom property creations, individual contact retrievals, and partial contact updates via HTTP PATCH requests.
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:101-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L101-L112), [apps/web/lib/integrations/hubspot/api.ts:8-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L8-L14)
### Custom Contact Properties
When the OAuth callback completes, `waitUntil()` triggers `createPropertiesBatch()` to register Dub-specific tracking fields under contact object type `0-1`. The definitions are supplied by `HUBSPOT_DUB_CONTACT_PROPERTIES`, which configures three custom properties (`dub_id`, `dub_link`, and `dub_partner_email`) within the standard `contactinformation` group.
| Property Label | Internal Name | Data Type | Field Type | Group Name | Form Field Allowed |
| :--- | :--- | :--- | :--- | :--- | :--- |
| Dub Click ID | `dub_id` | `string` | `text` | `contactinformation` | `true` |
| Dub Link | `dub_link` | `string` | `text` | `contactinformation` | `false` |
| Dub Partner Email | `dub_partner_email` | `string` | `text` | `contactinformation` | `false` |
Sources: [apps/web/app/ee/api/hubspot/callback/route.ts:106-111](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts#L106-L111), [apps/web/lib/integrations/hubspot/constants.ts:17-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts#L17-L40)
> [!NOTE]
> The `dub_id` property explicitly enables `formField: true`, permitting it to be rendered directly inside HubSpot forms for automated tracking capture.
Sources: [apps/web/lib/integrations/hubspot/constants.ts:17-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/constants.ts#L17-L25)
### API Client and Contact Update Operations
The `HubSpotApi` class provides core methods for inspecting and updating CRM records. Requests include a Bearer token in the `Authorization` header and automatically apply `Content-Type: application/json` when a request body is present. Responses are parsed and validated against Zod schemas before being returned to callers.
```typescript
async updateContact({
contactId,
properties,
}: {
contactId: number | string;
properties: Record;
}) {
try {
const result = await this.fetch(`/objects/contacts/${contactId}`, {
method: "PATCH",
body: {
properties,
},
});
return result;
} catch (error) {
console.error(
`[HubSpot] Failed to update contact ${contactId}: ${error}`,
);
return null;
}
}
```
Sources: [apps/web/lib/integrations/hubspot/api.ts:16-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L16-L53), [apps/web/lib/integrations/hubspot/api.ts:86-108](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L86-L108)
Contact data retrieval via `getContact()` specifically requests properties for email, names, lifecyclestage, and all three Dub tracking fields:
```typescript
async getContact(contactId: number | string) {
try {
const contact = await this.fetch(
`/objects/contacts/${contactId}?properties=email,firstname,lastname,dub_id,dub_link,dub_partner_email,lifecyclestage`,
);
return hubSpotContactSchema.parse(contact);
} catch (error) {
console.error(
`[HubSpot] Failed to retrieve contact ${contactId}: ${error}`,
);
return null;
}
}
```
Sources: [apps/web/lib/integrations/hubspot/api.ts:56-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/api.ts#L56-L69)
## Webhook Ingestion and Verification
### Webhook Subscription Configuration
HubSpot webhook events are managed through metadata definitions specifying target endpoints, concurrency limits, and active CRM object subscriptions. The integration targets `https://app.dub.co/api/hubspot/webhook` with a maximum concurrency of 10 requests. Subscriptions monitor CRM objects (`contact` and `deal`) for creations and property changes.
| Subscription Type | Object Type | Property Name | Active |
| :--- | :--- | :--- | :--- |
| `object.creation` | `contact` | — | `true` |
| `object.creation` | `deal` | — | `true` |
| `object.propertyChange` | `deal` | `dealstage` | `true` |
| `object.propertyChange` | `contact` | `lifecyclestage` | `true` |
Sources: [packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json:4-34](https://github.com/blade47/dub/blob/HEAD/packages/hubspot-app/src/app/webhooks/webhooks-hsmeta.json#L4-L34)
### Signature Verification and Event Fan-Out
Incoming webhooks at `/api/hubspot/webhook` are received as raw text. The endpoint validates the `X-HubSpot-Signature` header against the `HUBSPOT_CLIENT_SECRET` environment variable by generating a SHA-256 hash of the concatenated secret and raw request body, comparing them using `timingSafeCompare`.
```typescript
const rawBody = await req.text();
const signature = req.headers.get("X-HubSpot-Signature");
if (!signature) {
throw new DubApiError({
code: "bad_request",
message: "Missing X-HubSpot-Signature header.",
});
}
if (!HUBSPOT_CLIENT_SECRET) {
throw new DubApiError({
code: "internal_server_error",
message: "Missing HUBSPOT_CLIENT_SECRET environment variable.",
});
}
const sourceString = HUBSPOT_CLIENT_SECRET + rawBody;
const expectedHash = crypto
.createHash("sha256")
.update(sourceString)
.digest("hex");
if (!timingSafeCompare(signature, expectedHash)) {
throw new DubApiError({
code: "unauthorized",
message: "Invalid webhook signature.",
});
}
```
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:13-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L13-L45)
> [!WARNING]
> Requests lacking the `X-HubSpot-Signature` header or failing constant-time comparison are rejected immediately with `bad_request` or `unauthorized` error codes before any JSON parsing occurs.
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:18-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L18-L45)
Because HubSpot can dispatch multiple events within a single HTTP payload, the endpoint normalizes the body into an array and fans out each event independently using `enqueueBatchJobs` via QStash. This ensures slow or failing events do not block processing for the rest of the batch.
```typescript
const events = JSON.parse(rawBody) as any[];
const finalEvents = Array.isArray(events) ? events : [events];
const qstashResponse = await enqueueBatchJobs(
finalEvents.map((event) => ({
queueName: "process-hubspot-webhook",
url: `${APP_DOMAIN_WITH_NGROK}/api/hubspot/webhook/process`,
deduplicationId: event.eventId,
method: "POST",
body: event,
})),
);
```
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:47-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L47-L62)
### Asynchronous Queue Processing
Individual webhook events dispatched by QStash arrive at `/api/hubspot/webhook/process`. The processing flow executes the following sequence:
`req.text()` → `verifyQstashSignature()` → `hubSpotWebhookSchema.parse()` → `prisma.installedIntegration.findFirst()` → `hubSpotOAuthProvider.refreshTokenForInstallation()` → `getHubSpotEventAction()` → event routing (`trackHubSpotLeadEvent` / `trackHubSpotSaleEvent`) → `captureWebhookLog()`.
```typescript
try {
const rawBody = await req.text();
await verifyQstashSignature({
req,
});
body = JSON.parse(rawBody);
const { objectTypeId, portalId, subscriptionType } =
hubSpotWebhookSchema.parse(body);
const installation = await prisma.installedIntegration.findFirst({
where: {
integration: {
slug: "hubspot",
},
credentials: {
path: "$.hub_id",
equals: portalId,
},
},
include: {
project: {
select: {
id: true,
stripeConnectId: true,
webhookEnabled: true,
},
},
},
});
```
Sources: [apps/web/app/ee/api/hubspot/webhook/process/route.ts:27-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts#L27-L60)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Batch fan-out via QStash** | Isolates failures per event; prevents batch-wide transaction rollbacks | Higher HTTP request volume and dependency on QStash service |
| **Constant-time signature check** | Mitigates timing attacks during HMAC signature verification | Requires explicit byte-length handling or utility wrappers |
| **Prisma JSON path credential lookup** | Directly queries encrypted portal IDs within stored credential JSON blobs | Couples database schema queries to JSON structure paths (`$.hub_id`) |
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:50-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L50-L62), [apps/web/app/ee/api/hubspot/webhook/route.ts:40-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L40-L45), [apps/web/app/ee/api/hubspot/webhook/process/route.ts:40-49](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/process/route.ts#L40-L49)
## Lead and Conversion Tracking
### Overview
Lead and conversion tracking in Dub parses incoming HubSpot contact and deal webhook payloads, extracts associated click metadata (`dub_id`), and records conversion events against workspace projects using the core `trackLead` API. Incoming events are routed based on `objectTypeId`, `subscriptionType`, and the configured `leadTriggerEvent` settings.
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:10-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L10-L31), [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L5-L16)
### Event Action Resolution
The function `getHubSpotEventAction` inspects incoming webhook properties to determine whether an event should trigger lead tracking, sale tracking, or be skipped entirely.
```typescript
export function getHubSpotEventAction({
event,
settings,
}: {
event: Pick<
z.infer,
"objectTypeId" | "subscriptionType" | "propertyName" | "propertyValue"
>;
settings: z.infer;
}): "trackLead" | "trackSale" | "skip" {
const { objectTypeId, subscriptionType, propertyName, propertyValue } = event;
const { leadTriggerEvent } = settings;
const isCreated = subscriptionType === "object.creation";
const isPropertyChanged = subscriptionType === "object.propertyChange";
```
Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:5-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L5-L20)
| Object Type ID (`objectTypeId`) | Subscription Type (`subscriptionType`) | Property Filter / Condition | Action Result |
| :--- | :--- | :--- | :--- |
| `0-1` (Contacts) | `object.creation` | None | `trackLead` (Deferred) |
| `0-1` (Contacts) | `object.propertyChange` | `leadTriggerEvent === "lifecycleStageReached"` | `trackLead` |
| `0-3` (Deals) | `object.creation` | `leadTriggerEvent === "dealCreated"` | `trackLead` |
| `0-3` (Deals) | `object.propertyChange` | `propertyName === "dealstage"` && `newDealStage === leadDealStageId` | `trackLead` |
| `0-3` (Deals) | `object.propertyChange` | `propertyName === "dealstage"` && `newDealStage === closedWonDealStageId` | `trackSale` |
Sources: [apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts:21-66](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/get-hubspot-event-action.ts#L21-L66)
### Lead Event Processing Call Chain
When processing a HubSpot webhook event for lead creation or stage progression, `trackHubSpotLeadEvent` coordinates validation, CRM fetching, customer lookups, and tracking API calls.
The execution walkthrough follows this path:
`trackHubSpotLeadEvent()` → `hubSpotLeadEventSchema.parse()` → `hubSpotApi.getContact()` / `hubSpotApi.getDeal()` → `prisma.customer.findFirst()` → `trackLead()` → `updateHubSpotContact()`.
```typescript
const trackLeadResult = await trackLead({
clickId: properties.dub_id,
eventName: "Sign up",
customerEmail: properties.email,
customerExternalId: properties.email,
customerName,
mode: "deferred",
workspace,
source: "hubspot",
commissionSource: CommissionSource.hubspot,
});
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:10-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L10-L61)
> [!WARNING]
> Deferred lead tracking on contact creation (`objectTypeId === "0-1"` and `subscriptionType === "object.creation"`) requires the contact property `dub_id` to be present. If `dub_id` is missing from the retrieved contact properties, the tracking execution terminates early with a descriptive string response.
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:33-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L33-L45)
### Deal-Associated Final Lead Tracking
For deal-triggered lead events (`objectTypeId === "0-3"`), `trackFinalLead` retrieves the deal object, extracts associated contacts from `deal.associations.contacts.results`, and fetches contact details to associate the conversion with an existing customer record in Prisma.
```typescript
const trackFinalLead = async ({
dealId,
workspace,
hubSpotApi,
}: {
dealId: number;
workspace: Pick;
hubSpotApi: HubSpotApi;
}) => {
const deal = await hubSpotApi.getDeal(dealId);
if (!deal) {
return `No deal found for deal ${dealId}.`;
}
const { properties, associations } = deal;
const contact = associations?.contacts?.results?.[0];
if (!contact) {
return `No contact found for deal ${dealId}.`;
}
const contactInfo = await hubSpotApi.getContact(contact.id);
```
Sources: [apps/web/lib/integrations/hubspot/track-lead.ts:180-210](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts#L180-L210)
## Deal Ingestion and Sale Attribution
### Deal Ingestion and Sale Attribution
When a deal webhook event matches the configured closed-won criteria, HubSpot deal and contact properties are parsed to attribute and record revenue conversion sales through Dub's internal tracking services.
### Deal Sale Processing Call Chain
The execution path for processing a closed-won sale event coordinates webhook verification, deal retrieval, contact association lookups, customer identification, and sale recording:
`POST` (route handler) → `trackHubSpotSaleEvent()` → `hubSpotSaleEventSchema.parse()` → `hubSpotApi.getDeal()` → `hubSpotApi.getContact()` → `prisma.customer.findFirst()` → `trackSale()`.
```typescript
const deal = await hubSpotApi.getDeal(objectId);
if (!deal) {
return `No deal found for deal ${objectId}`;
}
const { id: dealId, properties, associations } = deal;
if (!properties.amount) {
return `Amount is not set for deal ${dealId}`;
}
```
Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:42-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L42-L52)
### Deal and Contact Schema Properties
HubSpot deals and associated contacts are validated against strict Zod schemas before revenue amounts and metadata are extracted for attribution.
| Schema Model | Property Field | Type | Description |
| :--- | :--- | :--- | :--- |
| `hubSpotDealSchema` | `id` | `string` | Unique HubSpot deal identifier |
| `hubSpotDealSchema` | `properties.dealname` | `string` | Name of the CRM deal |
| `hubSpotDealSchema` | `properties.amount` | `string \| null` | Monetary value of the deal |
| `hubSpotDealSchema` | `properties.dealstage` | `string` | Current pipeline stage ID of the deal |
| `hubSpotDealSchema` | `associations.contacts` | `object` | Associated contact references on the deal |
| `hubSpotSaleEventSchema` | `subscriptionType` | `literal("object.propertyChange")` | Restricted to property change events |
| `hubSpotSaleEventSchema` | `propertyName` | `literal("dealstage")` | Restricted to deal stage property updates |
Sources: [apps/web/lib/integrations/hubspot/schema.ts:62-81](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L62-L81), [apps/web/lib/integrations/hubspot/schema.ts:98-103](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L98-L103)
> [!WARNING]
> Sale attribution via `trackHubSpotSaleEvent` enforces strict filter validation: the incoming event must have `subscriptionType === "object.propertyChange"`, `propertyName === "dealstage"`, and a `propertyValue` matching the workspace's configured `closedWonDealStageId` (defaulting to `"closedwon"` case-insensitively).
Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:24-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L24-L36)
### Customer Resolution and Sale Recording
Once the deal and contact info are fetched, `trackHubSpotSaleEvent` queries Prisma to locate the corresponding customer by matching email or external IDs against contact properties, and then invokes `trackSale` with converted cent amounts and HubSpot metadata.
```typescript
await trackSale({
customerExternalId: customer.externalId!,
amount: Number(properties.amount) * 100,
eventName: `${properties.dealname} ${properties.dealstage}`,
paymentProcessor: "custom",
invoiceId: dealId,
workspace,
metadata: {
hubspotDealId: dealId,
hubspotContactId: contactInfo.id,
},
source: "hubspot",
commissionSource: CommissionSource.hubspot,
});
```
Sources: [apps/web/lib/integrations/hubspot/track-sale.ts:84-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-sale.ts#L84-L97)
## Integration Settings and Management
### Integration Settings and Management
### Overview
Workspace administrators configure and manage the HubSpot integration preferences directly through the Dub workspace UI. The `HubSpotSettings` component allows users to choose lead attribution trigger events, configure stage identifiers, and save preferences via server actions, while token lifecycle operations handle token validity checks, secure decryption, and portal uninstallation.
Sources: [apps/web/lib/integrations/hubspot/ui/settings.tsx:17-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/ui/settings.tsx#L17-L43), [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)
### Settings Schema Options
The integration settings are validated and typed using Zod schemas, defining default behaviors and constraints for event triggers and stage identifiers.
| Setting Field | Type | Default Value | Description |
| :--- | :--- | :--- | :--- |
| `leadTriggerEvent` | `enum` | `"dealCreated"` | Event that triggers final lead tracking for the contact (`lifecycleStageReached`, `dealCreated`, `dealStageReached`) |
| `leadLifecycleStageId` | `string \| null` | `null` | Contact lifecycle stage ID representing a qualified lead (used when trigger is `lifecycleStageReached`) |
| `leadDealStageId` | `string \| null` | `null` | Deal stage ID representing a qualified lead (used when trigger is `dealStageReached`) |
| `closedWonDealStageId` | `string \| null` | `"closedwon"` | Deal stage ID representing a closed won deal |
Sources: [apps/web/lib/integrations/hubspot/schema.ts:18-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/schema.ts#L18-L46)
### Preference Update Execution Flow
Updating integration settings follows a secure server action pipeline from client interaction through validation and database persistence:
`HubSpotSettings` (UI form submit) → `updateHubSpotSettingsAction` (auth action client) → schema extension & rule validation → `prisma.installedIntegration.findFirst()` → `prisma.installedIntegration.update()` → `revalidatePath()`.
```typescript
export const updateHubSpotSettingsAction = authActionClient
.inputSchema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace } = ctx;
const {
leadTriggerEvent,
leadLifecycleStageId,
leadDealStageId,
closedWonDealStageId,
} = parsedInput;
if (leadTriggerEvent === "dealStageReached") {
if (!leadDealStageId) {
throw new Error("Lead deal stage ID is required.");
}
if (
leadDealStageId.toLowerCase() === closedWonDealStageId?.toLowerCase()
) {
throw new Error(
"Lead deal stage ID must be different from the closed won deal stage ID.",
);
}
}
```
Sources: [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts:14-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts#L14-L37)
> [!WARNING]
> When `leadTriggerEvent` is set to `"dealStageReached"`, the server action strictly enforces that `leadDealStageId` is provided and that it differs case-insensitively from `closedWonDealStageId`.
Sources: [apps/web/lib/integrations/hubspot/update-hubspot-settings.ts:25-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/update-hubspot-settings.ts#L25-L36)
### Token Lifecycle Management
The `HubSpotOAuthProvider` manages authentication token validity, decryption of credentials retrieved from the database, automatic refreshing of expired tokens with a 60-second buffer, and uninstallation requests sent to HubSpot's external install API.
```typescript
async refreshTokenForInstallation(
installation: InstalledIntegration,
): Promise {
let token = hubSpotAuthTokenSchema.parse(installation.credentials);
token = {
...token,
access_token: decryptOrPassthrough(token.access_token),
refresh_token: decryptOrPassthrough(token.refresh_token),
};
if (this.isTokenValid(token)) {
return token;
}
const newToken = await this.refreshToken(token.refresh_token);
const credentials = {
...newToken,
created_at: Date.now(),
};
await prisma.installedIntegration.update({
where: {
id: installation.id,
},
data: {
credentials: {
...credentials,
access_token: encrypt(credentials.access_token),
refresh_token: encrypt(credentials.refresh_token),
},
},
});
return credentials;
}
```
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:16-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L16-L52)
> [!TIP]
> `isTokenValid` evaluates token expiration using a 60-second safety buffer (`const buffer = 60 * 1000`) before the actual `expires_in` timestamp elapses, refreshing tokens proactively to prevent mid-request expiration errors.
Sources: [apps/web/lib/integrations/hubspot/oauth.ts:88-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/oauth.ts#L88-L97)
## Related
- [[Conversion and Event Tracking]]
---
## Technical docs: GET Get pending PayPal payouts
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-payouts/getadminpaypalpayouts
## Parameters
## Responses
## Try It
---
## Technical docs: Google Ads Attribution
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/google-ads-attribution
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/integrations/google-ads/upload-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts)
- [apps/web/app/ee/api/google-ads/conversion-actions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts)
- [apps/web/lib/integrations/google-ads/api.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts)
- [apps/web/app/ee/api/google-ads/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/callback/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/track/lead/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/client/route.ts)
- [apps/web/app/ee/api/google-ads/upload-conversion/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/app/ee/api/track/sale/client/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/client/route.ts)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/lib/integrations/google-ads/ui/settings.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/ui/settings.tsx)
- [apps/web/app/ee/api/track/lead/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/lead/route.ts)
- [apps/web/scripts/create-integration.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts)
- [apps/web/app/ee/api/shopify/pixel/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/app/ee/api/track/sale/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/sale/route.ts)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts)
- [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts)
- [apps/web/lib/integrations/shopify/create-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts)
- [apps/web/lib/integrations/google-ads/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts)
- [apps/web/lib/api/conversions/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts)
- [apps/web/lib/api/customers/reattribute-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts)
## Overview
Google Ads Attribution integrates Dub with Google Ads to bridge referral tracking and campaign optimization by automatically uploading offline click conversions. It solves the fragmentation between click acquisition and downstream conversion data, empowering workspaces to attribute leads and sales back to the specific Google Ads clicks that drove them. The system manages secure OAuth credential lifecycles, maps internal workspace event names to Google Ads conversion actions, extracts and propagates GCLID identifiers across the tracking layer, and executes background offline upload pipelines. By connecting conversion ingestion workflows with automated Google Ads reporting, this integration enables precise ROI measurement and campaign performance tuning.
Sources: [apps/web/scripts/create-integration.ts:13-22](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/create-integration.ts#L13-L22), [apps/web/lib/integrations/google-ads/upload-conversion.ts:105-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L105-L223), [apps/web/lib/integrations/google-ads/oauth.ts:11-220](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L11-L220)
## OAuth Flow and Credential Management
### Overview
The Google Ads OAuth subsystem oversees the complete installation lifecycle, secure token exchange, encrypted credential persistence, and concurrent token refreshing using distributed locking via Upstash Redis. When a user initiates the installation process, the application constructs an authorization request URL with offline access and consent prompts, storing the request state in Redis with a 30-minute expiration. Upon callback completion, the route validates session ownership, enforces workspace owner permissions and plan capabilities, and persists encrypted tokens alongside inferred customer settings.
Sources: [apps/web/app/ee/api/google-ads/callback/route.ts:30-135](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/callback/route.ts#L30-L135), [apps/web/lib/integrations/google-ads/oauth.ts:21-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L21-L38)
### Installation Lifecycle Call-Chain
When a user completes authorization, Google redirects to the callback route where a sequence of validation, token processing, and database installation steps execute.
```
GET(req) → googleAdsOAuthProvider.exchangeCodeForToken() → prisma.project.findUniqueOrThrow() → googleAdsAuthTokenSchema.parse() → GoogleAdsApi.listAccessibleCustomers() → prisma.installedIntegration.findFirst() → googleAdsSettingsSchema.parse() → installIntegration() → waitUntil(googleAdsInstalledWorkspaces.add()) → redirect()
```
1. **`GET(req)`**: Entry point handling the incoming OAuth redirect request.
2. **`googleAdsOAuthProvider.exchangeCodeForToken()`**: Exchanges the authorization code for an OAuth token payload.
3. **`prisma.project.findUniqueOrThrow()`**: Retrieves workspace metadata and verifying user membership roles.
4. **`googleAdsAuthTokenSchema.parse()`**: Validates token structures and encrypts both `access_token` and `refresh_token` fields.
5. **`GoogleAdsApi.listAccessibleCustomers()`**: Queries accessible accounts to infer login customer identifiers.
6. **`prisma.installedIntegration.findFirst()`**: Checks for existing integration installation records.
7. **`googleAdsSettingsSchema.parse()`**: Parses combined existing and new customer settings.
8. **`installIntegration()`**: Persists the encrypted credentials and workspace settings to the database.
9. **`waitUntil(googleAdsInstalledWorkspaces.add())`**: Registers the newly installed workspace in the background.
10. **`redirect()`**: Navigates the user back to the workspace Google Ads settings page.
Sources: [apps/web/app/ee/api/google-ads/callback/route.ts:25-142](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/callback/route.ts#L25-L142)
### Token Refreshing and Concurrency Locking
When an access token expires or nears expiration, `getAccessToken()` evaluates token validity using a 60-second buffer. If expired, it attempts to acquire a Redis-backed lock before hitting the Google OAuth token endpoint.
> [!WARNING]
> If a refresh lock cannot be acquired on the first attempt, the process does not fail immediately. Instead, it enters a polling loop using `waitForRefreshedCredentials()` that waits between 200ms and 400ms per iteration up to a 5-second deadline to catch a token refreshed concurrently by another worker thread.
Sources: [apps/web/lib/integrations/google-ads/oauth.ts:40-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L40-L78), [apps/web/lib/integrations/google-ads/oauth.ts:160-177](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L160-L177), [apps/web/lib/integrations/google-ads/oauth.ts:210-219](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L210-L219)
### Configuration and Provider Parameters
The Google Ads OAuth provider is initialized with fixed configuration parameters defining the authorization endpoints, scope, and Redis key prefixes.
| Configuration Key | Value / Source | Purpose |
| :--- | :--- | :--- |
| `name` | `"Google Ads"` | Provider identifier used in error logging |
| `clientId` | `process.env.GOOGLE_ADS_CLIENT_ID!` | Google API OAuth client identifier |
| `clientSecret` | `process.env.GOOGLE_ADS_CLIENT_SECRET!` | Google API OAuth client secret |
| `authUrl` | `"https://accounts.google.com/o/oauth2/v2/auth"` | Initial user authorization endpoint |
| `tokenUrl` | `"https://oauth2.googleapis.com/token"` | Token exchange and refresh endpoint |
| `redirectUri` | `${APP_DOMAIN_WITH_NGROK}/api/google-ads/callback` | OAuth redirect callback URI |
| `redisStatePrefix` | `"google-ads:oauth:state"` | Redis key prefix for storing CSRF state |
| `bodyFormat` | `"form"` | Request payload format for token endpoints |
| `authorizationMethod` | `"body"` | Method for passing client credentials |
Sources: [apps/web/lib/integrations/google-ads/oauth.ts:222-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L222-L233)
## Google Ads API Client Surface
### Overview
The low-level Google Ads API client wrapper manages request signing, customer hierarchy inference, search queries, and conversion uploads. It provides methods for interacting directly with the Google Ads REST endpoints and Data Manager API.
Sources: [apps/web/lib/integrations/google-ads/api.ts:32-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L32-L503)
### Request Signing and Headers
All requests made through `googleAdsFetch` attach required authorization headers, developer credentials, and optional login customer context. The `getGoogleAdsHeaders` function constructs these headers from request options.
| Header Key | Source / Value | Purpose |
| :--- | :--- | :--- |
| `Authorization` | `Bearer ${accessToken}` | OAuth access token for authentication |
| `developer-token` | `process.env.GOOGLE_ADS_DEVELOPER_TOKEN!` | Google Ads developer token |
| `Content-Type` | `application/json` | Specifies JSON payload formatting |
| `login-customer-id` | `loginCustomerId` (hyphens removed) | Manager customer ID context when querying client accounts |
Sources: [apps/web/lib/integrations/google-ads/api.ts:32-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L32-L47)
> [!WARNING]
> If a request returns a non-OK status, `googleAdsFetch` parses the response text and error details, throwing an error containing the request path, status code, and formatted API error message.
Sources: [apps/web/lib/integrations/google-ads/api.ts:81-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L81-L88)
### Conversion Actions and Data Manager Uploads
The client wrapper interacts with both the Google Ads API for querying resources and the Data Manager API for event ingestion.
* **`listUploadClickConversionActions(customerId)`**: Executes a search stream query filtering for `UPLOAD_CLICKS` conversion actions with an `ENABLED` status, mapping the results through validation schemas.
* **`uploadClickConversion(...)`**: Constructs destination and event payloads before submitting offline click conversions to the Data Manager API endpoint (`events:ingest`).
Sources: [apps/web/lib/integrations/google-ads/api.ts:415-503](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L415-L503)
> [!TIP]
> New integrations must use the Data Manager API (`events:ingest`) rather than `ConversionUploadService.UploadClickConversions` when uploading offline click conversions.
Sources: [apps/web/lib/integrations/google-ads/api.ts:441-443](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/api.ts#L441-L443)
## Conversion Action and Event Mapping
### Overview
The conversion action and event mapping subsystem allows workspaces to bridge internal business telemetry with Google Ads conversion reporting. Through the workspace integration settings interface and accompanying server actions, administrators associate Dub lead and sale event names with verified Google Ads conversion actions.
Sources: [apps/web/lib/integrations/google-ads/ui/settings.tsx:72-343](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/ui/settings.tsx#L72-L343), [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:26-141](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L26-L141)
### API Endpoint and Client Workflow
When an administrator selects a Google Ads customer account within the settings interface, the client fetches available conversion actions by invoking the workspace API route.
The call-chain execution order for listing conversion actions follows this sequence:
1. `GET` route handler (`apps/web/app/ee/api/google-ads/conversion-actions/route.ts`) validates workspace permissions and installed integration status.
2. `googleAdsOAuthProvider.getAccessToken()` retrieves a valid OAuth token for the installation.
3. `getLoginCustomerIdCandidates()` computes manager hierarchy options based on customer settings.
4. `GoogleAdsApi` constructor instantiates the API client with the token, login customer context, and target customer ID.
5. `googleAdsApi.listUploadClickConversionActions(customerId)` queries the Google Ads API for enabled upload-click conversion actions.
Sources: [apps/web/app/ee/api/google-ads/conversion-actions/route.ts:15-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/conversion-actions/route.ts#L15-L89)
> [!WARNING]
> If a candidate login customer ID throws a `USER_PERMISSION_DENIED` error during conversion action retrieval, the iteration catches the error and tests the next candidate ID in the list before failing.
Sources: [apps/web/app/ee/api/google-ads/conversion-actions/route.ts:65-88](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/conversion-actions/route.ts#L65-L88)
### Validation and Server Action Execution
Once lead and sale event mappings are configured in the settings form, submitting changes triggers the `updateGoogleAdsSettingsAction` server action. This action enforces strict validation checks on customer association, format prefixes, and mapping uniqueness.
| Validation Check | Trigger Condition | Error Message / Outcome |
| :--- | :--- | :--- |
| Plan Capability | Workspace plan lacks advanced features | `"Google Ads integration is only available on Advanced and Enterprise plans."` |
| Installation Check | Integration record not found in database | `"Google Ads integration is not installed on your workspace."` |
| Customer Selection | `customerId` is missing while mappings are populated | `"A Google Ads account is required to configure conversion actions."` |
| Resource Prefix | Mapping `conversionAction` does not start with `customers/{id}/conversionActions/` | `"Invalid lead conversion action."` or `"Invalid sale conversion action."` |
| Event Uniqueness | Duplicate event names assigned across mappings | Handled by `getGoogleAdsEventMappingsError` |
Sources: [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:43-122](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L43-L122)
> [!IMPORTANT]
> The helper function `uniqueMappingEventNames` automatically sanitizes event name arrays by passing them through `new Set()` to remove duplicate entries prior to persisting settings in PostgreSQL.
Sources: [apps/web/lib/integrations/google-ads/update-google-ads-settings.ts:18-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts#L18-L24)
## Click Identifier Capture and Propagation
### Overview
The tracking layer extracts and propagates tracking identifiers and request metadata during click and visit ingestion. Incoming requests to tracking routes capture query parameters, perform identity hashing, verify workspace allowed hostnames, and persist rich telemetry records to Tinybird and Upstash Redis.
Sources: [apps/web/app/ee/api/track/click/route.ts:25-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L25-L158), [apps/web/app/ee/api/track/visit/route.ts:18-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/visit/route.ts#L18-L104), [apps/web/lib/tinybird/record-click.ts:22-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L22-L236)
### Click Ingestion and Caching Flow
When a click tracking request arrives at the `/api/track/click` endpoint, it parses domain and key arguments, validates request parameters via Zod schemas, and executes concurrent cache lookups for click identifiers and link metadata.
The call-chain execution order for recording a click event follows this sequence:
1. `POST` route handler (`apps/web/app/ee/api/track/click/route.ts`) parses the request body using `trackClickSchema`.
2. `getIdentityHash(req)` computes a unique visitor identity hash.
3. `redisGlobalWithTimeout.mget()` checks `recordClickCache` and `linkCache` in Upstash Redis.
4. `getLinkWithPartner()` queries Planetscale if the link is not cached.
5. `verifyAnalyticsAllowedHostnames()` validates the request origin against workspace allowed hostnames.
6. `recordClick()` ingests the click event into Tinybird and publishes streams to Upstash.
Sources: [apps/web/app/ee/api/track/click/route.ts:58-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L58-L158)
> [!NOTE]
> If a cached click identifier exists in Redis for the given identity hash, the endpoint reuses `cachedClickId` instead of allocating a new nanoid or duplicating the Tinybird ingestion record.
Sources: [apps/web/app/ee/api/track/click/route.ts:78-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L78-L80), [apps/web/app/ee/api/track/click/route.ts:114-114](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/track/click/route.ts#L114-L114)
### Telemetry Record Construction
The `recordClick` function aggregates geographical data, user-agent details, and request headers into a comprehensive click payload before dispatching it to external analytics sinks.
| Payload Field | Source / Derivation | Fallback Value |
| :--- | :--- | :--- |
| `timestamp` | Explicit parameter or current ISO string | `new Date().toISOString()` |
| `identity_hash` | `getIdentityHash(req)` | `""` |
| `click_id` | Passed `clickId` parameter | `null` (aborts recording) |
| `ip` | `ipAddress(req)` (Vercel) or `LOCALHOST_IP` | `""` (omitted for EU countries) |
| `continent` | `x-vercel-ip-continent` header | `""` |
| `country` | `geolocation(req).country` | `"Unknown"` |
| `device` | Capitalized `ua.device.type` | `"Desktop"` |
| `referer` | `referrer` param or `referer` header | `"(direct)"` |
Sources: [apps/web/lib/tinybird/record-click.ts:53-161](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L53-L161)
> [!WARNING]
> Requests originating from European Union country codes (`EU_COUNTRY_CODES`) automatically have their IP address stripped and recorded as an empty string to comply with privacy regulations.
Sources: [apps/web/lib/tinybird/record-click.ts:122-137](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L122-L137)
### Asynchronous Event Ingestion
Once the telemetry object is compiled, `recordClick` caches the click ID in Redis for 5 minutes and dispatches background tasks via `waitUntil` to ensure non-blocking response delivery.
```typescript
waitUntil(
(async () => {
const response = await Promise.allSettled([
fetchWithRetry(
`${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`,
},
body: JSON.stringify(clickData),
},
).then((res) => res.json()),
recordClickCache.set({
domain,
key,
identityHash,
clickId,
}),
publishLinkClickEvent({
linkId,
timestamp: clickData.timestamp,
...(workspaceId && url && { workspaceId }),
...(programId && partnerId && { programId, partnerId }),
}),
publishWorkspaceClickEvent(clickData),
]);
})(),
);
```
Sources: [apps/web/lib/tinybird/record-click.ts:169-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tinybird/record-click.ts#L169-L233)
## Downstream Conversion Tracking Triggers
### Overview
Downstream conversion tracking triggers bridge lead generation and sale ingestion workflows with offline upload pipelines. Whenever a lead or sale occurs via API endpoints, Shopify webhooks, or Stripe event integrations, the underlying application logic packages conversion attributes and invokes `queueGoogleAdsConversionUpload` inside Vercel's `waitUntil` asynchronous execution context. This architecture ensures that ingestion routes return HTTP responses immediately while offline conversion payloads are queued for delivery.
Sources: [apps/web/lib/api/conversions/track-lead.ts:226-231](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L226-L231), [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:213-297](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L213-L297), [apps/web/lib/integrations/shopify/create-lead.ts:139-165](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L139-L165), [apps/web/lib/api/conversions/track-sale.ts:348-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L348-L667)
### Lead and Sale Ingestion Hooks
Conversion triggers are embedded directly into core domain workflows. For leads, conversion hooks execute after verifying click metadata, recording the event in Tinybird, incrementing link statistics, and updating workspace usage. For sales, conversion hooks execute alongside partner commission creation and revenue metrics updates.
```typescript
queueGoogleAdsConversionUpload({
workspaceId: workspace.id,
eventType: EventType.lead,
eventId: leadData.event_id,
eventName: leadData.event_name,
conversionDateTime: new Date().toISOString(),
conversionCount: 1,
click: {
id: clickData.click_id,
url: clickData.url,
},
})
```
Sources: [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:284-295](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L284-L295), [apps/web/lib/integrations/shopify/create-lead.ts:153-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L153-L164), [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)
> [!NOTE]
> Sales upload triggers pass financial metrics including `conversionValue` (derived from `saleData.amount`) and `currencyCode` (derived from `saleData.currency`), whereas lead triggers pass `conversionCount: 1` and omit currency details.
Sources: [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)
### Trigger Workflow Parameters
The fields transmitted from ingestion workflows to the conversion upload queue vary depending on whether the event represents a lead or a sale transaction.
| Parameter | Type | Lead Workflow Source | Sale Workflow Source |
| :--- | :--- | :--- | :--- |
| `workspaceId` | `string` | `workspace.id` | `workspace.id` |
| `eventType` | `EventType` | `EventType.lead` | `EventType.sale` |
| `eventId` | `string` | `leadData.event_id` | `saleData.event_id` |
| `eventName` | `string` | `leadData.event_name` | `saleData.event_name` |
| `conversionDateTime` | `string` | `new Date().toISOString()` | `new Date().toISOString()` |
| `conversionCount` | `number` | `1` | Omitted |
| `conversionValue` | `number` | Omitted | `saleData.amount` |
| `currencyCode` | `string` | Omitted | `saleData.currency` |
| `click.id` | `string` | `clickData.click_id` | `saleData.click_id` |
| `click.url` | `string` | `clickData.url` | `saleData.url` |
Sources: [apps/web/app/ee/api/stripe/integration/webhook/utils/sync-customer.ts:284-295](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/utils/sync-customer.ts#L284-L295), [apps/web/lib/integrations/shopify/create-lead.ts:153-164](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts#L153-L164), [apps/web/lib/api/conversions/track-sale.ts:655-667](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts#L655-L667)
## Offline Conversion Upload Pipeline
### Overview
The offline conversion upload pipeline handles queueing conversion payloads, publishing them via QStash, executing background worker tasks, formatting currency values, and logging errors. When a conversion payload is prepared, `queueGoogleAdsConversionUpload()` first verifies the presence of valid click identifiers (`gclid`, `gbraid`, or `wbraid`) on the click URL using `extractGoogleAdsClickId()`. It checks if the workspace has installed the Google Ads integration via `googleAdsInstalledWorkspaces.has()`. If the currency is not a zero-decimal currency, it normalizes major currency units by dividing `conversionValue` by `100`.
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:21-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L21-L68)
> [!NOTE]
> Non-zero-decimal currencies such as USD and EUR are divided by 100 before queueing because Google Ads Data Manager expects amounts in major currency units rather than minor currency subunits (cents).
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:60-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L60-L68)
### Background Execution and QStash Dispatch
Once validated and normalized, the payload is published to QStash targeting `/api/google-ads/upload-conversion`. The publish operation specifies a maximum of 3 retries and a deterministic deduplication ID formatted as `google-ads-${payload.workspaceId}-${payload.eventId}`.
```typescript
const response = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/upload-conversion`,
body: payload,
retries: 3,
deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`,
});
```
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:71-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L71-L76)
The background API route handler `POST` is wrapped with `withCron` and parses the incoming request body against `googleAdsConversionUploadSchema`, immediately invoking `uploadGoogleAdsConversion(payload)`.
```typescript
export const POST = withCron(async ({ rawBody }) => {
const payload = googleAdsConversionUploadSchema.parse(JSON.parse(rawBody));
const { message, status } = await uploadGoogleAdsConversion(payload);
if (status === "failed") {
return logAndRespond(message, { status: 500, logLevel: "error" });
}
return logAndRespond(message, {
logLevel: status === "skipped" ? "warn" : "info",
});
});
```
Sources: [apps/web/app/ee/api/google-ads/upload-conversion/route.ts:9-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/ee/api/google-ads/upload-conversion/route.ts#L9-L21)
### Conversion Upload Execution Lifecycle
The core upload worker function `uploadGoogleAdsConversion()` executes a robust sequence of checks, token acquisition, and retry-backed API transmissions.
```mermaid
graph TD
A[Parse Payload] --> B[Find Installed Integration]
B --> C{Integration Found?}
C -- No --> D[Return Skipped]
C -- Yes --> E[Parse Settings & Resolve Mapping]
E --> F{Mapping & Customer ID Valid?}
F -- No --> G[Return Skipped]
F -- Yes --> H[Extract Click ID & Fetch OAuth Token]
H --> I[Instantiate GoogleAdsApi Client]
I --> J[Execute uploadClickConversion with Retries]
J --> K{Success?}
K -- Yes --> L[Return Uploaded Status]
K -- No --> M[Log Error & Flush Logger]
```
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:105-244](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L105-L244)
The worker queries Prisma for the `InstalledIntegration` matching `GOOGLE_ADS_INTEGRATION_ID` and the workspace ID, parses its settings, and resolves the conversion mapping based on `eventType` (`lead` or `sale`) and `eventName`. It acquires an OAuth access token, sets up the `GoogleAdsApi` instance with credentials and optional `loginCustomerId`, and loops up to 3 retry attempts with exponential backoff (`1000 * Math.pow(2, attempt)`).
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:121-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L121-L223)
### Pipeline Error Handling and Logging
Errors encountered during queueing or upload execution are captured by Axiom loggers with structured correlation metadata. If queueing fails, `queueGoogleAdsConversionUpload()` logs the error under `google-ads.queue_conversion_failed`, flushes the logger, and rethrows the error. If upload processing encounters an unhandled exception after exhausting all retry attempts, `uploadGoogleAdsConversion()` records an error log under `google-ads.upload_conversion_failed` and flushes the logger.
Sources: [apps/web/lib/integrations/google-ads/upload-conversion.ts:83-97](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L83-L97), [apps/web/lib/integrations/google-ads/upload-conversion.ts:229-244](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/upload-conversion.ts#L229-L244)
## Related
- [[Conversion and Event Tracking]]
---
## Technical docs: GET Get admin payouts and timeseries
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin-payouts/getadminpayouts
## Parameters
## Responses
## Try It
---
## Technical docs: Shopify Integration
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/shopify-integration
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/shopify/pixel/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/shopify/integration/webhook/orders-paid.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts)
- [apps/web/lib/integrations/shopify/attribute-via-discount-code.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts)
- [apps/web/app/ee/api/shopify/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts)
- [apps/web/lib/integrations/shopify/process-order.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts)
- [apps/web/lib/integrations/shopify/create-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-sale.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/lib/jobs/handlers/process-shopify-order-job.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/process-shopify-order-job.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/invoice-paid.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts)
- [apps/web/app/ee/api/shopify/integration/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts)
- [apps/web/lib/discounts/discount-provider-shopify.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/discounts/discount-provider-shopify.ts)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/lib/integrations/shopify/create-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-lead.ts)
- [apps/web/app/api/dub/webhook/sale-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/sale-created.ts)
- [apps/web/lib/rewardful/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/lib/integrations/hubspot/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/hubspot/track-lead.ts)
- [apps/web/lib/integrations/shopify/schema.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/schema.ts)
- [apps/web/lib/api/conversions/track-sale.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-sale.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/lib/integrations/shopify/checkout-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts)
- [apps/web/scripts/customers/upheal/sync-stripe-invoices.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/upheal/sync-stripe-invoices.ts)
- [apps/web/scripts/stripe/backfill-discount-code-sales.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/stripe/backfill-discount-code-sales.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
## Overview
The Shopify integration for Dub enables real-time conversion analytics and affiliate attribution by connecting Shopify merchants to workspaces. It manages the complete lifecycle of customer interactions, including OAuth installation callbacks, client-side web pixel tracking, server-side webhook ingestion, asynchronous order processing, discount code attribution, and commission publishing.
Sources: [apps/web/lib/integrations/shopify/schema.ts:1-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/schema.ts#L1-L51), [apps/web/app/ee/api/shopify/integration/callback/route.ts:25-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L25-L90), [apps/web/lib/jobs/handlers/process-shopify-order-job.ts:17-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/process-shopify-order-job.ts#L17-L68)
## Shopify OAuth Installation Callback
### Overview
The Shopify integration handshake is managed through the PATCH endpoint located at `apps/web/app/ee/api/shopify/integration/callback/route.ts`. This route handles workspace authentication, validates incoming payload schemas using Zod discriminated unions, updates project configuration in Prisma, and securely persists encrypted OAuth access tokens along with granted scopes.
Sources: [apps/web/app/ee/api/shopify/integration/callback/route.ts:1-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L1-L90)
### Request Schema & Handshake Actions
Incoming requests are parsed via `requestSchema`, which defines a discriminated union on the `action` property supporting store connections and disconnections.
| Action | Required Fields | Field Type & Validation | Purpose |
| :--- | :--- | :--- | :--- |
| `connect` | `action`, `shopifyStoreId`, `accessToken`, `scope` | `z.literal("connect")`, `z.string().min(1)`, `z.string().min(1)`, `z.string().min(1)` | Links a Shopify store identifier and stores encrypted credentials and scopes. |
| `disconnect` | `action`, `shopifyStoreId` | `z.literal("disconnect")`, `z.literal(null)` | Removes the Shopify store association from the workspace project. |
Sources: [apps/web/app/ee/api/shopify/integration/callback/route.ts:11-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L11-L23)
> [!IMPORTANT]
> Access to the callback route is strictly guarded by workspace middleware (`withWorkspace`). Callers must possess either the `owner` or `member` role and belong to workspaces subscribed to `business`, `advanced`, or `enterprise` plans.
Sources: [apps/web/app/ee/api/shopify/integration/callback/route.ts:86-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L86-L90)
### Call-Chain Execution Walkthrough
When an authenticated PATCH request hits the callback handler, execution proceeds through validation, database updates, credential encryption, and integration record management:
1. `parseRequestBody(req)` — Extracts and parses the incoming HTTP request body.
2. `requestSchema.parse(...)` — Validates the payload against the discriminated union schema for `connect` or `disconnect` actions.
3. `prisma.project.update(...)` — Updates the workspace project record in the database, setting or clearing the `shopifyStoreId`.
4. `installIntegration(...)` (conditional on `body.action === "connect"`):
- `encrypt(body.accessToken)` — Encrypts the raw Shopify OAuth access token prior to persistence.
- Saves an `InstalledIntegration` record binding the user, workspace, `SHOPIFY_INTEGRATION_ID`, and credentials.
5. `prisma.installedIntegration.delete(...)` (conditional on `body.action === "disconnect"`):
- Deletes the matching installed integration record using a composite unique key (`userId_integrationId_projectId`), silently catching any errors if absent.
Sources: [apps/web/app/ee/api/shopify/integration/callback/route.ts:26-70](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L26-L70)
> [!WARNING]
> If a Prisma database operation throws error code `P2002` (indicating a unique constraint violation), the handler catches it and throws a `DubApiError` with the conflict code, signaling that the specified Shopify store is already tied to another project.
Sources: [apps/web/app/ee/api/shopify/integration/callback/route.ts:72-78](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/callback/route.ts#L72-L78)
## Web Pixel Event Ingestion
### Overview
Client-side checkout and click tracking are handled by the Shopify Web Pixel route at `apps/web/app/ee/api/shopify/pixel/route.ts`. This endpoint receives tracking payloads from the storefront web pixel, validates request inputs, enforces rate limits, verifies underlying click events, and caches checkout-to-click associations in Redis before evaluating whether to dispatch asynchronous order processing jobs.
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:1-82](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L1-L82)
### Request Validation & Schema
Incoming POST requests are parsed using a Zod schema that expects three optional string properties: `clickId`, `checkoutToken`, and `shopDomain`.
| Field Name | Type | Nullable / Optional | Purpose |
| :--- | :--- | :--- | :--- |
| `clickId` | `string` | `.nullish()` | The unique click identifier associated with the affiliate visit. |
| `checkoutToken` | `string` | `.nullish()` | The Shopify checkout session token generated during checkout. |
| `shopDomain` | `string` | `.nullish()` | The domain name of the Shopify merchant store. |
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:14-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L14-L18)
> [!WARNING]
> Both `checkoutToken` and `clickId` are strictly required to proceed. If either field is missing or nullish, the route logs an error and immediately returns an HTTP "OK" response without performing further caching or job dispatch.
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:37-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L37-L45)
### Call-Chain Execution Walkthrough
When a valid pixel tracking request is received, execution proceeds through security checks, database lookups, and asynchronous cache updates:
1. `parseRequestBody(req)` & `inputSchema.parse(...)` — Extracts and validates the incoming JSON payload against expected fields.
2. `ratelimit().limit(...)` — Applies an Upstash rate limit keyed by `shopify-track-pixel:${ip}`, where the IP is derived via `ipAddress(req)` on Vercel or `LOCALHOST_IP` in local development.
3. `getClickEvent({ clickId })` — Queries Tinybird to verify that the specified `clickId` exists in the click event store.
4. `waitUntil(...)` — Schedules background execution on Vercel to decouple downstream cache persistence from the HTTP response:
- `shopifyCheckoutCache.set({ checkoutToken, fields: { clickId } })` — Writes the checkout-to-click association into Redis using a pipeline (`hset`, `expire`, `hgetall`) with a TTL of 1 hour (`SHOPIFY_CHECKOUT_CACHE_TTL_SECONDS`).
- `tryDispatchShopifyOrderJob({ checkoutToken, checkout })` — Inspects cached checkout state and attempts to trigger the order processing job if all required fields are present.
Sources: [apps/web/app/ee/api/shopify/pixel/route.ts:26-75](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/pixel/route.ts#L26-L75), [apps/web/lib/integrations/shopify/checkout-cache.ts:6-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L6-L43), [apps/web/lib/integrations/shopify/checkout-cache.ts:61-128](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L61-L128)
> [!TIP]
> The `tryDispatchShopifyOrderJob` helper uses an atomic Redis `hsetnx` operation on the `dispatched` field to claim the checkout. This prevents duplicate job dispatches when both the client-side web pixel event and the server-side orders-paid webhook arrive concurrently.
Sources: [apps/web/lib/integrations/shopify/checkout-cache.ts:93-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L93-L101)
## Orders-Paid Webhook Dispatch
### Overview
Server-side webhook ingestion, HMAC validation, and job dispatch orchestration are handled by the Shopify webhook route at `apps/web/app/ee/api/shopify/integration/webhook/route.ts` and its topic handlers. This route processes incoming Shopify webhook events, verifies HMAC cryptographic signatures, validates workspace configurations, and routes payloads to specific handlers such as `ordersPaid` in `apps/web/app/ee/api/shopify/integration/webhook/orders-paid.ts`.
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:25-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L25-L155), [apps/web/app/ee/api/shopify/integration/webhook/orders-paid.ts:11-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts#L11-L125)
### Webhook Route Processing & Validation
The POST endpoint begins by extracting raw request text, headers, and Shopify-specific metadata including `x-shopify-topic`, `x-shopify-hmac-sha256`, and `x-shopify-shop-domain`.
| Header / Field | Type | Purpose |
| :--- | :--- | :--- |
| `x-shopify-topic` | `string` | Identifies the Shopify event topic (e.g., `orders/paid`, `app/uninstalled`). |
| `x-shopify-hmac-sha256` | `string` | The cryptographic HMAC signature provided by Shopify for request verification. |
| `x-shopify-shop-domain` | `string` | The shop domain used to look up the associated workspace in Prisma. |
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L28-L33)
> [!WARNING]
> Unless running in local development (`isLocalDev`), the webhook route computes an HMAC SHA256 digest of the raw request body using `SHOPIFY_WEBHOOK_SECRET` and compares it to the incoming signature using `timingSafeCompare`. If verification fails, the route immediately returns an HTTP 401 response.
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:39-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L39-L54)
### Relevant Webhook Topics
The system tracks specific mandatory compliance topics and order events, which are validated against a predefined `relevantTopics` set before workspace lookup and dispatch occur.
| Topic Name | Handler Function | Purpose |
| :--- | :--- | :--- |
| `orders/paid` | `ordersPaid` | Processes paid orders, checks customer records or discount codes, and queues jobs. |
| `app/uninstalled` | `appUninstalled` | Handles app uninstallation cleanup for the shop domain. |
| `customers/data_request` | `customersDataRequest` | Handles GDPR customer data requests. |
| `customers/redact` | `customersRedact` | Handles GDPR customer data erasure requests. |
| `shop/redact` | `shopRedact` | Handles shop data deletion requests. |
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:15-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L15-L23), [apps/web/app/ee/api/shopify/integration/webhook/route.ts:96-126](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L96-L126)
### Call-Chain Execution Walkthrough
When an `orders/paid` webhook event is successfully authenticated and matched to a workspace, execution flows through the `ordersPaid` handler and checkout cache manager:
1. `POST` — The main webhook route receives the HTTP request, validates headers, verifies the HMAC signature, parses the event JSON, and dispatches control to `ordersPaid` based on the `orders/paid` topic.
2. `ordersPaid` — Parses the order using `shopifyOrderSchema`, checks for existing customer records or matching partner discount codes, and falls back to checking `note_attributes` for a `dubClickId` before writing to cache or queuing processing.
3. `tryDispatchShopifyOrderJob` — Evaluates whether the cached checkout contains all required fields (`order`, `workspaceId`, `clickId`) and is not already dispatched, then attempts an atomic Redis `hsetnx` claim on the `dispatched` field.
4. `delete` — Upon successful job dispatch, `shopifyCheckoutCache.delete(checkoutToken)` removes the temporary checkout entry from Redis.
5. `createKey` — The cache helper generates the underlying Redis key using `shopifyCheckoutCache.createKey(checkoutToken)`, formatted with the `shopify:checkout:` prefix.
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:96-102](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L96-L102), [apps/web/app/ee/api/shopify/integration/webhook/orders-paid.ts:11-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts#L11-L125), [apps/web/lib/integrations/shopify/checkout-cache.ts:45-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L45-L51), [apps/web/lib/integrations/shopify/checkout-cache.ts:61-128](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L61-L128)
```mermaid
sequenceDiagram
participant WebhookRoute as route.ts
participant OrdersPaid as ordersPaid
participant CacheModule as checkout-cache.ts
participant Redis as Redis Cache
WebhookRoute->>OrdersPaid: POST /api/shopify/integration/webhook (orders/paid)
OrdersPaid->>CacheModule: tryDispatchShopifyOrderJob({ checkoutToken, checkout })
CacheModule->>Redis: shopifyCheckoutCache.delete(checkoutToken)
CacheModule->>CacheModule: shopifyCheckoutCache.createKey(checkoutToken)
```
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:96-102](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L96-L102), [apps/web/app/ee/api/shopify/integration/webhook/orders-paid.ts:11-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts#L11-L125), [apps/web/lib/integrations/shopify/checkout-cache.ts:45-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L45-L51), [apps/web/lib/integrations/shopify/checkout-cache.ts:61-128](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/checkout-cache.ts#L61-L128)
> [!NOTE]
> Unlike other webhook topics whose request logs are captured immediately within the main route handler using `waitUntil` and `captureWebhookLog`, `orders/paid` log capture is deferred and handled directly by `processShopifyOrderJob` after the order completes processing.
Sources: [apps/web/app/ee/api/shopify/integration/webhook/route.ts:142-152](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/route.ts#L142-L152)
## Background Order Reconciliation
### Overview
Background order reconciliation executes asynchronously through the queue worker handler to resolve orders against existing customer records, partner program discount codes, or web pixel click attribution identifiers.
Sources: [apps/web/lib/jobs/handlers/process-shopify-order-job.ts:17-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/process-shopify-order-job.ts#L17-L20), [apps/web/lib/integrations/shopify/process-order.ts:10-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts#L10-L18)
### Call-Chain Execution Walkthrough
When `processShopifyOrderJob` picks up a queued order payload, execution flows through the worker handler and reconciliation router:
1. `processShopifyOrderJob` — The job handler fetches workspace configuration via `prisma.project.findUniqueOrThrow`, initializes request logging parameters, and invokes `processShopifyOrder`.
2. `processShopifyOrder` — Inspects incoming order properties, checking first for an existing customer record, then evaluating workspace discount codes, and finally falling back to click ID tracking.
3. `prisma.customer.findUnique` — Queries the database for an existing customer using the compound key `projectId_externalId`.
4. `getLeadEvent` — Retrieves the customer lead event from Tinybird when an existing customer match is confirmed.
5. `createShopifySale` — Records the sale event linked to resolved customer and lead data.
Sources: [apps/web/lib/jobs/handlers/process-shopify-order-job.ts:20-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/process-shopify-order-job.ts#L20-L48), [apps/web/lib/integrations/shopify/process-order.ts:10-58](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts#L10-L58)
```mermaid
sequenceDiagram
participant JobHandler as processShopifyOrderJob
participant ProcessOrder as processShopifyOrder
participant Prisma as prisma.customer
participant Tinybird as getLeadEvent
participant Sale as createShopifySale
JobHandler->>ProcessOrder: processShopifyOrder({ order, workspace, clickId })
ProcessOrder->>Prisma: prisma.customer.findUnique()
alt Existing Customer Found
Prisma-->>ProcessOrder: customer
ProcessOrder->>Tinybird: getLeadEvent({ customerId })
Tinybird-->>ProcessOrder: leadData
ProcessOrder->>Sale: createShopifySale(...)
end
```
Sources: [apps/web/lib/jobs/handlers/process-shopify-order-job.ts:20-48](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/handlers/process-shopify-order-job.ts#L20-L48), [apps/web/lib/integrations/shopify/process-order.ts:10-58](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts#L10-L58)
### Lead and Sale Resolution Routing
The reconciliation engine routes orders through three distinct attribution branches depending on customer state, discount code usage, and pixel tracking data.
| Attribution Path | Condition | Resolution Action | Return Attribution Type |
| :--- | :--- | :--- | :--- |
| Existing Customer | `orderCustomer` exists and matching `customer` record found | Retrieves Tinybird lead event and records sale against existing customer | `existing_lead` |
| Discount Code | Customer missing, but `discountCodes` match workspace `defaultProgramId` discount codes | Attributes via discount code link and records sale | `discount_code` |
| Click Tracking | Customer and discount codes missing, but `clickId` is present | Creates Shopify lead via `createShopifyLead` and records sale | `click` |
Sources: [apps/web/lib/integrations/shopify/process-order.ts:26-140](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts#L26-L140)
> [!WARNING]
> When an existing customer record is located but the corresponding Tinybird lead event cannot be fetched (`!leadData`), `processShopifyOrder` explicitly throws an error rather than skipping the order. This ensures the background job retries instead of duplicating customer records.
Sources: [apps/web/lib/integrations/shopify/process-order.ts:45-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/process-order.ts#L45-L51)
## Affiliate Discount Code Attribution
### Overview
When an incoming Shopify order lacks an explicit click tracking identifier (`clickId`) or existing customer record, attribution falls back to affiliate discount codes applied at checkout. The `attributeViaDiscountCode` function handles matching discount codes to affiliate links, generating synthetic traffic records, and propagating conversion metrics across analytics and partner systems.
Sources: [apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:19-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts#L19-L27)
### Synthetic Click Generation and Customer Creation
Because orders attributed via discount codes lack a pre-existing browser click event, the platform manufactures synthetic records to maintain relational integrity across analytics backends. The billing address country code is resolved against `COUNTRIES_TO_CONTINENTS` to build geographic context for a fake click.
```typescript
const clickEvent = await recordFakeClick({
link,
customer: {
continent: billingAddressCountry
? COUNTRIES_TO_CONTINENTS[billingAddressCountry] ?? "Unknown"
: "Unknown",
country: billingAddressCountry ?? "Unknown",
region: billingAddress?.province ?? "Unknown",
},
});
```
Sources: [apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:30-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts#L30-L42)
> [!IMPORTANT]
> The database customer record is created *before* the lead event is pushed to Tinybird. This ordering guarantees that a database constraint violation on `projectId_externalId` (P2002) will never leave behind an orphaned Tinybird click record.
Sources: [apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:47-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts#L47-L65)
### Call-Chain Execution Walkthrough
The attribution flow executes through a series of distinct asynchronous operations, orchestrating local database persistence, analytics recording, and downstream webhooks:
1. `attributeViaDiscountCode` — Entry point receiving the `order`, `workspace`, and matched `link`.
2. `recordFakeClick` — Generates a synthetic click entry in Tinybird using extracted billing geography.
3. `prisma.customer.create` — Persists the newly attributed customer tied to `link.id`, `link.programId`, and `link.partnerId`.
4. `recordLead` — Transmits the manufactured lead event (`Checkout with discount code`) to Tinybird with a generated `nanoid(16)` event ID.
5. `prisma.link.update` — Increments lead counters on the associated link and updates `lastLeadAt`.
6. `queuePartnerCommissionCreation` — Enqueues a partner commission when `link.programId` and `link.partnerId` are present.
7. `Promise.allSettled` — Dispatches parallel notification tasks including workspace webhooks (`sendWorkspaceWebhook`), Google Ads conversion uploads (`queueGoogleAdsConversionUpload`), and partner postbacks (`sendPartnerPostback`).
Sources: [apps/web/lib/integrations/shopify/attribute-via-discount-code.ts:19-185](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts#L19-L185)
### Shopify Discount Code Management and Retries
Discount provider operations manage the lifecycle of Shopify discount codes via GraphQL mutations. When creating discount codes using `discountCodeBasicCreate`, duplicate code collisions trigger an automated retry mechanism up to `MAX_ATTEMPTS` (3 attempts), appending a 2-character nanoid to the conflicting code string.
| Parameter | Type | Default / Mapping | Purpose |
| :--- | :--- | :--- | :--- |
| `recurringCycleLimit` | `number` | `discount.maxDuration === null ? 0 : discount.maxDuration === 0 ? 1 : discount.maxDuration` | Maps Dub subscription duration limits to Shopify billing cycles |
| `customerSelection` | Object | `{ all: true }` | Specifies that all customers are eligible for the discount code |
| `appliesOncePerCustomer` | boolean | `true` | Restricts redemption to a single use per customer profile |
| `shouldRetry` | boolean | `true` | Controls whether duplicate code creation errors trigger suffix retries |
Sources: [apps/web/lib/discounts/discount-provider-shopify.ts:35-170](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/discounts/discount-provider-shopify.ts#L35-L170)
> [!WARNING]
> If a discount code creation attempt fails with an unrecognized error code or exceeds `MAX_ATTEMPTS` during collision resolution, a `DiscountProviderError` with type `CREATE_FAILED` is thrown, aborting the transaction.
Sources: [apps/web/lib/discounts/discount-provider-shopify.ts:225-232](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/discounts/discount-provider-shopify.ts#L225-L232)
## Sale Record and Commission Publishing
### Overview
Once an order has been successfully attributed to a customer and link, the integration transitions to recording the financial transaction and publishing associated partner commissions. This step processes the monetary payload from the Shopify order, enforces idempotency checks via Redis cache keys, writes analytics records to Tinybird, updates link and customer aggregates, and asynchronously dispatches downstream workspace webhooks and partner postbacks.
Sources: [apps/web/lib/integrations/shopify/create-sale.ts:20-231](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-sale.ts#L20-L231)
### Call-Chain Execution Walkthrough
The sale record and commission publishing flow executes through a structured sequence of checks, data mutations, and asynchronous dispatches:
1. `createShopifySale` — Entry point receiving the normalized `order`, `customerId`, `workspaceId`, and `leadData`.
2. `redis.set` — Checks and sets a 7-day TTL idempotency lock using key format `dub_sale_events:linkId:${linkId}:invoiceId:${invoiceId}` with `nx: true` to prevent duplicate processing of the same invoice.
3. `prisma.customer.findUniqueOrThrow` — Retrieves the existing customer record and evaluates conversion status via `isFirstConversion`.
4. `Promise.all` — Executes atomic parallel persistence:
- `recordSale(saleData)` — Writes the sale event to Tinybird.
- `prisma.link.update` — Increments link sales counts, sale amounts, and conditionally increments conversions and `lastConversionAt`.
- `prisma.project.update` — Increments workspace usage metrics.
- `prisma.customer.update` — Updates customer sales aggregates and sets `firstSaleAt` if unpopulated.
- `shopifyCheckoutCache.delete(checkoutToken)` — Clears the temporary checkout cache entry.
5. `queuePartnerCommissionCreation` — Enqueues partner commission creation when `link.programId` and `link.partnerId` are present, specifying `CommissionSource.shopify`.
6. `waitUntil` — Triggers background processing via `Promise.allSettled` for workflow execution (`executeWorkflows`), link stats synchronization (`syncPartnerLinksStats`), workspace webhooks (`sendWorkspaceWebhook`), and partner postbacks (`sendPartnerPostback`).
Sources: [apps/web/lib/integrations/shopify/create-sale.ts:20-227](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-sale.ts#L20-L227)
> [!IMPORTANT]
> The Redis idempotency check uses `nx: true` with a 7-day expiration (`ex: 60 * 60 * 24 * 7`) keyed on `linkId` and `invoiceId`. If the key already exists, a `ShopifyError` is thrown immediately to abort duplicate order processing.
Sources: [apps/web/lib/integrations/shopify/create-sale.ts:41-55](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-sale.ts#L41-L55)
### Sale Event Data Mapping Reference
The sale event object constructed before persistence contains specific properties mapped from the Shopify order and lead context:
| Property | Type | Source / Calculation | Purpose |
| :--- | :--- | :--- | :--- |
| `workspace_id` | string | Passed argument (`workspaceId`) | Associates the sale with the workspace project |
| `event_id` | string | `nanoid(16)` | Unique identifier for the sale event |
| `event_name` | string | `"Purchase"` | Standardized event identifier for analytics |
| `payment_processor`| string | `"shopify"` | Identifies the origin payment gateway |
| `amount` | number | `Math.round(Number(shopMoney.amount) * 100)` | Transaction total rounded to the nearest cent |
| `currency` | string | `shopMoney.currency_code.toLowerCase()` | Lowercased currency code (e.g., `"usd"`) |
| `invoice_id` | string | `order.confirmation_number` | Shopify confirmation number used as invoice ID |
| `metadata` | string | `JSON.stringify(order)` | Serialized full Shopify order payload |
Sources: [apps/web/lib/integrations/shopify/create-sale.ts:31-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/shopify/create-sale.ts#L31-L67)
## Related
- [[Conversion and Event Tracking]]
- [[Commission Rules and Rewards]]
---
## Technical docs: Affiliate Migration Importers
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/affiliate-migration-importers
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/rewardful/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts)
- [apps/web/app/ee/api/cron/import/rewardful/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts)
- [apps/web/app/ee/api/cron/import/firstpromoter/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/lib/rewardful/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/importer.ts)
- [apps/web/lib/tolt/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts)
- [apps/web/lib/tapfiliate/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tapfiliate/importer.ts)
- [apps/web/app/ee/api/cron/import/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/bitly/route.ts)
- [apps/web/lib/firstpromoter/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts)
- [apps/web/app/ee/api/cron/import/tapfiliate/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts)
- [apps/web/app/ee/api/cron/import/tolt/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts)
- [apps/web/ui/modals/import-firstpromoter-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx)
- [apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts)
- [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts)
- [apps/web/lib/rewardful/import-campaigns.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts)
- [apps/web/lib/firstpromoter/import-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts)
- [apps/web/lib/lemonsqueezy/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts)
- [apps/web/lib/firstpromoter/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/importer.ts)
- [apps/web/lib/rewardful/import-partners.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts)
- [apps/web/lib/partnerstack/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/importer.ts)
- [apps/web/lib/partnerstack/import-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
- [apps/web/lib/rewardful/import-customers.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts)
- [apps/web/ui/modals/import-rewardful-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx)
- [apps/web/ui/modals/import-partnerstack-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx)
- [apps/web/lib/tolt/importer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/importer.ts)
- [apps/web/lib/actions/partners/start-rewardful-import.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-rewardful-import.ts)
## Overview
Affiliate Migration Importers provide a robust, asynchronous background processing architecture designed to migrate existing affiliate programs, campaign configurations, partners, customers, and historical commissions from external platforms like Rewardful, FirstPromoter, Tolt, PartnerStack, and Lemon Squeezy into Dub. The system solves complex data portability and synchronization challenges during platform transitions by securely caching API credentials, chunking large batch payloads via Upstash Redis, and orchestrating multi-step background jobs through QStash cron route handlers. By automating currency conversion, Stripe coupon mapping, customer external ID reconciliation, and Tinybird event logging, the importer suite ensures data integrity and continuous analytics synchronization without disrupting live workspace operations.
Sources: [apps/web/lib/rewardful/importer.ts:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/importer.ts#L1-L50), [apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L13-L42), [apps/web/lib/rewardful/import-commissions.ts:35-138](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L138)
## Modal UI and Import Actions
### Overview
The frontend layer exposes dedicated React modal components and custom hooks for each supported external affiliate platform: FirstPromoter, Rewardful, and PartnerStack. Each modal handles state collection, credential entry, step progression, and server action triggers to initialize background import jobs.
Sources: [apps/web/ui/modals/import-firstpromoter-modal.tsx:19-201](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L19-L201), [apps/web/ui/modals/import-rewardful-modal.tsx:39-219](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L39-L219), [apps/web/ui/modals/import-partnerstack-modal.tsx:13-188](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L13-L188)
### Modal Component Forms and Parameters
Each modal component interacts with URL query parameters via `useImportModalParam` to manage visibility and cleans up parameters upon dismissal. The credential forms capture platform-specific keys and tokens before invoking authenticated server actions using `next-safe-action`.
| Modal Component | Hook Export | Required Form Fields | Success Route |
| :--- | :--- | :--- | :--- |
| `ImportFirstPromoterModal` | `useImportFirstPromoterModal()` | `apiKey`, `accountId` | `/[slug]/program/partners` |
| `ImportRewardfulModal` | *Managed via URL param* | `apiToken`, `campaignIds` | `/[slug]/program/partners` |
| `ImportPartnerStackModal` | `useImportPartnerStackModal()` | `publicKey`, `secretKey` | `/[slug]/program/partners` |
Sources: [apps/web/ui/modals/import-firstpromoter-modal.tsx:66-178](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L66-L178), [apps/web/ui/modals/import-firstpromoter-modal.tsx:181-201](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-firstpromoter-modal.tsx#L181-L201), [apps/web/ui/modals/import-rewardful-modal.tsx:39-136](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L39-L136), [apps/web/ui/modals/import-rewardful-modal.tsx:180-219](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L180-L219), [apps/web/ui/modals/import-partnerstack-modal.tsx:13-167](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L13-L167), [apps/web/ui/modals/import-partnerstack-modal.tsx:169-188](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-partnerstack-modal.tsx#L169-L188)
### Rewardful Import Two-Step Flow
Unlike FirstPromoter and PartnerStack which execute in a single step, the Rewardful import modal features a multi-step wizard interface. Users first submit an API token, fetch their campaigns, and subsequently select specific campaign identifiers for import.
```mermaid
sequenceDiagram
autonumber
participant User
participant Modal as ImportRewardfulModal
participant TokenAction as setRewardfulTokenAction
participant SWR as SWRImmutable (/api/.../rewardful/campaigns)
participant ImportAction as startRewardfulImportAction
User->>Modal: Enter Rewardful API Token
Modal->>TokenAction: submit({ workspaceId, token })
TokenAction-->>Modal: Token saved successfully (onSuccess)
Modal->>Modal: Advance step to "campaigns"
Modal->>SWR: Fetch available campaigns
SWR-->>Modal: RewardfulCampaign[]
User->>Modal: Select campaigns & submit
Modal->>ImportAction: startRewardfulImport({ workspaceId, campaignIds })
ImportAction-->>Modal: Import queued successfully
Modal->>User: Toast notification & redirect to partners page
```
Sources: [apps/web/ui/modals/import-rewardful-modal.tsx:52-136](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/import-rewardful-modal.tsx#L52-L136)
### Server Action Execution Flow
The server action triggers enforce workspace permission checks before validating program prerequisites and queuing the background import job. For instance, `startRewardfulImportAction` executes the following sequence:
`authActionClient` → `throwIfNoPermission()` (checking roles `owner` or `member`) → `getDefaultProgramIdOrThrow()` → `getProgramOrThrow()` → domain & URL validation checks → `rewardfulImporter.queue()`.
> [!IMPORTANT]
> Both workspace domain and program URL must be explicitly set on the target program object before `startRewardfulImportAction` will successfully dispatch an import job to the queue.
Sources: [apps/web/lib/actions/partners/start-rewardful-import.ts:18-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-rewardful-import.ts#L18-L51)
## Cron Verification and Orchestration Endpoints
### Overview
Asynchronous cron route handlers orchestrate step-based data ingestion across all supported affiliate migration providers. Each provider exposes a `POST` route handler configured with `export const dynamic = "force-dynamic"` to handle incoming requests dispatched by QStash or internal cron runners. Request authentication and payload validation are strictly enforced before dispatching jobs to respective import handlers via conditional switch statements.
Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:11-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L11-L42), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:11-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L11-L48), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:11-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L11-L38), [apps/web/app/ee/api/cron/import/tolt/route.ts:12-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L12-L50), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:8-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L8-L26)
### Route Handler Execution Patterns and Verification
Route verification utilizes two distinct wrapper strategies depending on the migration target. Routes for Rewardful, FirstPromoter, and Tolt read raw request text and explicitly invoke `verifyQstashSignature({ req, rawBody })`, subsequently parsing request payloads with provider-specific Zod schemas inside a `try...catch` block wrapped with `handleAndReturnErrorResponse`. Conversely, Tapfiliate and Lemon Squeezy routes leverage the higher-order wrapper `withCron` alongside `logAndRespond`.
```mermaid
sequenceDiagram
autonumber
participant QStash as QStash / Cron Scheduler
participant Route as POST /api/cron/import/[provider]
participant Verify as verifyQstashSignature / withCron
participant Zod as Zod Schema Parse
participant Handler as Provider Action Handler
QStash->>Route: POST Request with raw body
Route->>Verify: Validate request signature
Verify-->>Route: Signature valid
Route->>Zod: Parse JSON body against payload schema
Zod-->>Route: Validated payload object
Route->>Handler: Switch on payload.action & execute
Handler-->>Route: Operation complete
Route-->>QStash: Return HTTP 200 "OK"
```
Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:13-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L13-L42), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:13-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L13-L48), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:13-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L13-L38), [apps/web/app/ee/api/cron/import/tolt/route.ts:14-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L14-L50), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:10-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L10-L26)
> [!NOTE]
> `verifyQstashSignature` requires access to the raw request text (`req.text()`) prior to JSON parsing to properly validate cryptographic headers supplied by QStash.
Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:15-16](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L15-L16), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:15-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L15-L20), [apps/web/app/ee/api/cron/import/tolt/route.ts:16-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L16-L21)
### Supported Import Actions by Provider
The migration cron handlers route incoming action strings to specific ingestion modules. Each provider supports a unique subset of import and maintenance routines.
| Provider | Supported Action Strings | Target Schema |
| :--- | :--- | :--- |
| **Rewardful** | `import-campaigns`, `import-partners`, `import-affiliate-coupons`, `import-customers`, `import-commissions` | `rewardfulImportPayloadSchema` |
| **FirstPromoter** | `import-campaigns`, `import-partners`, `import-customers`, `import-commissions`, `update-stripe-customers` | `firstPromoterImportPayloadSchema` |
| **Tapfiliate** | `import-groups`, `import-partners`, `import-customers`, `import-commissions`, `update-stripe-customers`, `cleanup-partners` | `tapfiliateImportPayloadSchema` |
| **Tolt** | `import-partners`, `import-links`, `import-customers`, `import-commissions`, `update-stripe-customers`, `cleanup-partners` | `toltImportPayloadSchema` |
| **Lemon Squeezy** | `import-partners`, `import-customers`, `import-commissions` | `lemonSqueezyImportPayloadSchema` |
Sources: [apps/web/app/ee/api/cron/import/rewardful/route.ts:8-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/rewardful/route.ts#L8-L36), [apps/web/app/ee/api/cron/import/firstpromoter/route.ts:7-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts#L7-L42), [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:7-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L7-L35), [apps/web/app/ee/api/cron/import/tolt/route.ts:8-44](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/tolt/route.ts#L8-L44), [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:5-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L5-L23)
## Campaign and Discount Setup
### Overview
The campaign import subsystem extracts Rewardful campaigns, maps them to Dub partner groups, establishes reward structures, converts associated Stripe coupons into Dub discount configurations, and triggers subsequent background tasks via QStash.
Sources: [apps/web/lib/rewardful/import-campaigns.ts:27-250](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L27-L250)
### Campaign Import Call-Chain Execution
The campaign migration executes through a precise sequence of database queries, API lookups, and transactional upserts:
`importCampaigns()` → `prisma.program.findUniqueOrThrow()` → `rewardfulImporter.getCredentials()` → `new RewardfulApi()` → `rewardfulApi.listCampaigns()` → `prisma.partnerGroup.upsert()` → `prisma.reward.create()` → `stripe.coupons.retrieve()` → `validateStripeCouponForDubDiscount()` → `stripeCouponToDubDiscount()` → `prisma.discount.create()` → `rewardfulImporter.queue()`
Sources: [apps/web/lib/rewardful/import-campaigns.ts:27-250](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L27-L250)
> [!NOTE]
> Rewardful's API can occasionally return `stripe_coupon_id: null` even when a campaign possesses a valid Stripe coupon. In such cases, the discount cannot be automatically mapped and must be manually recreated on Dub.
Sources: [apps/web/lib/rewardful/import-campaigns.ts:177-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L177-L179)
### Campaign to Group Mapping and Reward Configuration
During iteration over campaigns matching the payload's `campaignIds`, each campaign is upserted into the `PartnerGroup` table using a generated slug (`rewardful-${campaignId}`). Default group styles, holding periods, and default referral links are inherited from the program's default partner group.
```typescript
const createdGroup = await prisma.partnerGroup.upsert({
where: {
programId_slug: {
programId,
slug: groupSlug,
},
},
create: {
id: createId({ prefix: "grp_" }),
programId,
name: `(Rewardful) ${campaign.name}`,
slug: groupSlug,
color: randomValue(RESOURCE_COLORS),
logo,
wordmark,
brandColor,
holdingPeriodDays,
autoApprovePartnersEnabledAt,
...(additionalLinks && {
additionalLinks: sanitizeAdditionalLinks(additionalLinks),
}),
...(maxPartnerLinks && { maxPartnerLinks }),
...(linkStructure && { linkStructure }),
...(applicationFormData && { applicationFormData }),
...(landerData && { landerData }),
partnerGroupDefaultLinks: {
create: {
id: createId({ prefix: "pgdl_" }),
programId,
domain: program.domain,
url: program.url,
},
},
},
update: {},
});
```
Sources: [apps/web/lib/rewardful/import-campaigns.ts:87-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L87-L124)
### Reward Structure and Stripe Coupon Conversion
The importer maps Rewardful commission parameters directly onto Dub's `Reward` schema. Max commissions of `1` or max commission periods of `0` map to a `maxDuration` of `0` (indicating commissions for the first sale only). Flat reward amounts use `amountInCents`, while percentage rewards use `amountInPercentage`.
| Rewardful Property | Dub Reward Field | Transformation Rule |
| :--- | :--- | :--- |
| `max_commissions === 1` \|\| `max_commission_period_months === 0` | `maxDuration` | Set to `0` ("for the first sale"); otherwise takes `max_commission_period_months`. |
| `reward_type === "amount"` | `type` & `amountInCents` | Set type to `RewardStructure.flat` and assign `commission_amount_cents`. |
| `reward_type !== "amount"` | `type` & `amountInPercentage` | Set type to `RewardStructure.percentage` and assign `new Prisma.Decimal(commission_percent)`. |
Sources: [apps/web/lib/rewardful/import-campaigns.ts:152-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L152-L168)
Stripe coupons linked to campaigns are retrieved via Stripe Connect (`program.workspace.stripeConnectId`), validated, converted using `stripeCouponToDubDiscount`, and stored in the `Discount` table.
```typescript
const createdDiscount = await prisma.discount.create({
data: {
id: createId({ prefix: "disc_" }),
programId,
groupId: createdGroup.id,
amount: dubDiscountAttrs?.amount ?? 0,
type: dubDiscountAttrs?.type ?? "percentage",
maxDuration: dubDiscountAttrs?.maxDuration ?? null,
couponId: campaign.stripe_coupon_id,
defaultForPartnerGroup: {
connect: {
id: createdGroup.id,
},
},
},
});
```
Sources: [apps/web/lib/rewardful/import-campaigns.ts:208-224](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-campaigns.ts#L208-L224)
## Partner Ingestion and Link Creation
### Overview
Partner ingestion bridges external affiliate platforms (such as FirstPromoter and Rewardful) into Dub's unified partner and program enrollment architecture. Incoming platform partner entities are mapped to Dub's `Partner` and `ProgramEnrollment` models, assigned to specific partner groups, enriched with social handles or reward attributes, approved automatically, and paired with bulk-created referral tracking links.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:116-254](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L116-L254), [apps/web/lib/rewardful/import-partners.ts:164-257](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L164-L257)
### Partner Ingestion and Entity Mapping
The partner ingestion pipeline iterates through paginated partner lists from the upstream API provider, validating activity states and filtering eligible records before executing core database mutations.
```mermaid
sequenceDiagram
participant Importer as importPartners()
participant FP as External API
participant DB as Prisma DB
participant Sync as queuePartnerSearchSync()
Importer->>FP: listPartners({ page })
FP-->>Importer: affiliates[]
loop For each affiliate
Importer->>DB: prisma.partner.upsert()
DB-->>Importer: partner record
Importer->>DB: prisma.programEnrollment.upsert()
DB-->>Importer: programEnrollment record
Importer->>DB: approveLinkedApplication()
Importer->>DB: bulkCreateLinks()
end
Importer->>Sync: queuePartnerSearchSync()
```
Sources: [apps/web/lib/firstpromoter/import-partners.ts:52-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L52-L101), [apps/web/lib/rewardful/import-partners.ts:52-137](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L52-L137)
For FirstPromoter partners, social platforms are mapped across six core types (`website`, `youtube`, `twitter`, `linkedin`, `instagram`, `tiktok`) and upserted via `upsertPartnerPlatform()`. Rewardful partners evaluate campaign filters and require active states with positive lead counts before syncing metadata hashes into Redis.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:148-181](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L148-L181), [apps/web/lib/rewardful/import-partners.ts:65-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L65-L75), [apps/web/lib/rewardful/import-partners.ts:117-129](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L117-L129)
### Bulk Referral Link Creation
Once the partner and their program enrollment are established with an `approved` status, tracking links are generated using the program's domain and destination URL. If the program lacks a configured domain or URL, link creation is aborted with an error log.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:183-224](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L224), [apps/web/lib/rewardful/import-partners.ts:198-228](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L198-L228)
| Platform | Key Generation Source | Default Link Assignment |
| :--- | :--- | :--- |
| FirstPromoter | `campaign.ref_token || nanoid()` | Assigned to `partnerGroupDefaultLinkId` for the first campaign index (`idx === 0`). |
| Rewardful | `link.token || nanoid()` | Assigned to `partnerGroupDefaultLinkId` for the first index (`idx === 0`). |
Sources: [apps/web/lib/firstpromoter/import-partners.ts:226-238](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L226-L238), [apps/web/lib/rewardful/import-partners.ts:231-243](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L231-L243)
> [!WARNING]
> If partner link creation fails during bulk insertion, the database transaction for the partner and program enrollment remains committed. The enrollment ID is still preserved and passed to the search synchronization queue so that partner indexing is not permanently blocked by link generation errors.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:240-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L240-L252)
### Application Status Approval and Index Synchronization
Every imported partner's program enrollment is forced to an `approved` status on creation and update. Immediately following enrollment upsertion, `approveLinkedApplication()` is invoked using the enrollment's `applicationId` and the triggering `userId`.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:183-214](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L214), [apps/web/lib/rewardful/import-partners.ts:198-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L198-L223)
```typescript
const programEnrollment = await prisma.programEnrollment.upsert({
where: {
partnerId_programId: {
partnerId: partner.id,
programId: program.id,
},
},
create: {
id: createId({ prefix: "pge_" }),
programId: program.id,
partnerId: partner.id,
status: "approved",
groupId: group.id,
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
referralRewardId: group.referralRewardId,
customRewardId: group.customRewardId,
discountId: group.discountId,
},
update: {
status: "approved",
},
include: {
links: true,
},
});
await approveLinkedApplication({
applicationId: programEnrollment.applicationId,
userId,
});
```
Sources: [apps/web/lib/firstpromoter/import-partners.ts:183-214](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L183-L214)
Once batch page processing concludes, `queuePartnerSearchSync()` is called to update partner search indexes across the newly enrolled partner records.
Sources: [apps/web/lib/firstpromoter/import-partners.ts:97-101](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-partners.ts#L97-L101), [apps/web/lib/rewardful/import-partners.ts:133-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-partners.ts#L133-L136)
## Customer Ingestion and Activity Tracking
### Overview
Customer ingestion processes referral records in batches, validates campaign filters, checks for existing customer mappings via Stripe customer IDs or external IDs, and records synthetic click and lead events into Tinybird.
Sources: [apps/web/lib/rewardful/import-customers.ts:15-105](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L15-L105)
### Call-Chain Execution Walkthrough
The import pipeline follows an explicit sequence of API fetches, batch validations, and persistence calls:
`importCustomers()` → `rewardfulApi.listCustomers()` → `prisma.customer.findMany()` → `chunk()` → `createCustomer()` → `recordClick()` → `clickEventSchemaTB.parse()` → `prisma.customer.create()` → `recordLeadWithTimestamp()` & `prisma.link.update()` & `syncPartnerLinksStats()`.
Sources: [apps/web/lib/rewardful/import-customers.ts:15-297](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L15-L297)
### Customer External ID and Stripe ID Deduplication
Before creating individual customer records, the importer queries existing database records matching either the Stripe customer ID (`cus_...`) or the external customer ID (`referral.customer.id`) associated with the workspace project.
Sources: [apps/web/lib/rewardful/import-customers.ts:45-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L45-L75)
```typescript
const existingCustomers =
stripeCustomerIds.length === 0 && externalIds.length === 0
? []
: await prisma.customer.findMany({
where: {
OR: [
...(stripeCustomerIds.length > 0
? [{ stripeCustomerId: { in: stripeCustomerIds } }]
: []),
...(externalIds.length > 0
? [
{
projectId: workspace.id,
externalId: { in: externalIds },
},
]
: []),
],
},
});
```
Sources: [apps/web/lib/rewardful/import-customers.ts:56-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L56-L75)
> [!WARNING]
> If a referral lacks both a link token and a coupon token, or if the associated link cannot be resolved via `prisma.link.findFirst`, the customer creation is skipped and an error is logged to Tinybird with code `LINK_NOT_FOUND`.
Sources: [apps/web/lib/rewardful/import-customers.ts:143-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L143-L179)
### Tinybird Event Recording and Link Stats
When a valid customer is processed, a synthetic click request is generated to instantiate click metadata, which is parsed through `clickEventSchemaTB` with `bot: 0` and `qr: 0`. A customer row is then created in PostgreSQL, followed by asynchronous lead recording and link statistics updates.
Sources: [apps/web/lib/rewardful/import-customers.ts:210-296](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L210-L296)
```typescript
const clickData = await recordClick({
req: dummyRequest,
clickId: nanoid(16),
workspaceId: workspace.id,
linkId: link.id,
domain: link.domain,
key: link.key,
url: link.url,
skipRatelimit: true,
timestamp: new Date(referral.created_at).toISOString(),
});
const clickEvent = clickEventSchemaTB.parse({
...clickData,
bot: 0,
qr: 0,
});
```
Sources: [apps/web/lib/rewardful/import-customers.ts:210-236](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-customers.ts#L210-L236)
## Commission Processing and Analytics Sync
### Overview
Commission processing handles historical commission records across external affiliate providers such as Rewardful, Tolt, FirstPromoter, Lemon Squeezy, and PartnerStack. The importer validates transaction IDs, handles foreign currency conversion against cached Redis exchange rates (`fxRates:usd`), logs import validation errors into Tinybird, and records sales with timestamps. It also reconciles aggregate statistics by updating link stats and partner commission totals.
Sources: [apps/web/lib/rewardful/import-commissions.ts:35-232](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L232), [apps/web/lib/tolt/import-commissions.ts:36-246](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L36-L246), [apps/web/lib/firstpromoter/import-commissions.ts:35-252](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L35-L252), [apps/web/lib/lemonsqueezy/import-commissions.ts:50-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L50-L124), [apps/web/lib/partnerstack/import-commissions.ts:38-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L38-L125)
### Call-Chain Execution Walkthrough
The commission import pipeline coordinates API retrieval, customer data matching, currency normalization, and persistent record creation:
`importCommissions()` → `rewardfulApi.listCommissions()` → `prisma.customer.findMany()` → `getLeadEvents()` → `createCommission()` → `convertCurrencyWithFxRates()` → `prisma.commission.findUnique()` → `recordSaleWithTimestamp()` → `syncTotalCommissions()`.
Sources: [apps/web/lib/rewardful/import-commissions.ts:35-232](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L35-L232), [apps/web/lib/tolt/import-commissions.ts:36-246](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L36-L246), [apps/web/lib/partnerstack/import-commissions.ts:38-121](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L38-L121)
> [!WARNING]
> If a Rewardful referral lacks a valid Stripe customer ID starting with `cus_`, the commission is skipped and an error is logged to Tinybird with error code `STRIPE_CUSTOMER_NOT_FOUND`.
Sources: [apps/web/lib/rewardful/import-commissions.ts:178-189](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L178-L189)
### Status Mappings Across Providers
Each integrated platform maps its unique string-based status fields to standard Dub `CommissionStatus` enums (`pending`, `paid`, `refunded`, `fraud`, `canceled`).
| Provider | Platform Status Key | Dub CommissionStatus | Sources |
| :--- | :--- | :--- | :--- |
| **Rewardful** | `pending` / `due` | `pending` / `pending` | [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33) |
| **Rewardful** | `paid` / `voided` | `paid` / `canceled` | [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33) |
| **Tolt** | `pending` / `approved` | `pending` / `pending` | [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34) |
| **Tolt** | `paid` / `rejected` / `refunded` | `paid` / `canceled` / `refunded` | [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34) |
| **FirstPromoter** | `pending` / `approved` / `denied` | `pending` / `pending` / `canceled` | [apps/web/lib/firstpromoter/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L28-L33) |
| **PartnerStack** | `hold` / `pending` / `approved` | `fraud` / `pending` / `pending` | [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36) |
| **PartnerStack** | `declined` / `paid` / `scheduled` | `canceled` / `paid` / `pending` | [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36) |
| **Lemon Squeezy** | `paid` / `pending` / `refunded` | `paid` / `pending` / `refunded` | [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660) |
| **Lemon Squeezy** | `fraudulent` / `void` / `failed` | `fraud` / `canceled` / `canceled` | [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660) |
Sources: [apps/web/lib/rewardful/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L28-L33), [apps/web/lib/tolt/import-commissions.ts:28-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L28-L34), [apps/web/lib/firstpromoter/import-commissions.ts:28-33](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L28-L33), [apps/web/lib/partnerstack/import-commissions.ts:26-36](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L26-L36), [apps/web/lib/lemonsqueezy/import-commissions.ts:643-660](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L643-L660)
### Currency Conversion and USD Resolution
When processing sales and earnings amounts, the importer normalizes non-USD currencies using exchange rates stored in Redis under `fxRates:usd`. For Lemon Squeezy events, `resolveAmountUsd` checks explicit `amountUsd` fields first, falls back to raw amounts if the currency is USD, or computes conversions via `convertCurrencyWithFxRates`.
Sources: [apps/web/lib/rewardful/import-commissions.ts:205-231](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L205-L231), [apps/web/lib/tolt/import-commissions.ts:221-245](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L221-L245), [apps/web/lib/firstpromoter/import-commissions.ts:230-242](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L230-L242), [apps/web/lib/lemonsqueezy/import-commissions.ts:611-641](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L611-L641)
```typescript
function resolveAmountUsd({
amount,
amountUsd,
currency,
fxRates,
}: {
amount: number;
amountUsd: number | null | undefined;
currency: string;
fxRates: Record | null;
}): number | null {
if (amountUsd != null) {
return amountUsd;
}
if (currency.toUpperCase() === "USD") {
return amount;
}
if (!fxRates) {
return null;
}
const converted = convertCurrencyWithFxRates({
currency,
amount,
fxRates,
});
return converted.currency.toUpperCase() === "USD" ? converted.amount : null;
}
```
Sources: [apps/web/lib/lemonsqueezy/import-commissions.ts:611-641](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/lemonsqueezy/import-commissions.ts#L611-L641)
### Design Trade-Offs in Commission Importers
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| **Batch pagination with Redis state queueing** | Prevents serverless timeout limits during large historical imports | Requires state payload serialization via QStash cron queues | [apps/web/lib/rewardful/import-commissions.ts:54-107](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L54-L107), [apps/web/lib/tolt/import-commissions.ts:57-117](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/tolt/import-commissions.ts#L57-L117) |
| **Deduplication via composite keys (`invoiceId_programId`)** | Avoids duplicate commission records on retries or overlapping webhook periods | Relies on provider transaction identifiers or fallback string keys | [apps/web/lib/rewardful/import-commissions.ts:191-203](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L191-L203), [apps/web/lib/partnerstack/import-commissions.ts:159-166](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L159-L166) |
| **Asynchronous Tinybird error logging** | Captures invalid rows (e.g. missing Stripe IDs, self-referrals) without halting batch execution | Errors remain decoupled from primary PostgreSQL transactions | [apps/web/lib/rewardful/import-commissions.ts:182-188](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L182-L188), [apps/web/lib/firstpromoter/import-commissions.ts:181-198](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L181-L198) |
Sources: [apps/web/lib/rewardful/import-commissions.ts:54-203](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/rewardful/import-commissions.ts#L54-L203), [apps/web/lib/firstpromoter/import-commissions.ts:181-198](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/firstpromoter/import-commissions.ts#L181-L198), [apps/web/lib/partnerstack/import-commissions.ts:159-166](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partnerstack/import-commissions.ts#L159-L166)
## Historical Link Migration and Backfills
### Overview
Historical migration and maintenance scripts handle importing custom domain links and associated tag groups from external link shorteners like Bitly and Rebrandly, as well as executing complex customer and commission backfills. Workspace administrators initiate imports via API routes that verify tokens, sync missing domains to Vercel and Prisma, and dispatch background jobs via QStash.
Sources: [apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts:60-143](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts#L60-L143), [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:94-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts#L94-L165)
### Bitly and Rebrandly Import Endpoints
The workspace import endpoints interact with external APIs to inspect groups, domains, and tags before scheduling asynchronous cron tasks.
- `GET /api/workspaces/[idOrSlug]/import/bitly`: Retrieves active Bitly groups and fetches group tags using a Redis-stored bearer token.
- `POST /api/workspaces/[idOrSlug]/import/bitly`: Dispatches QStash JSON jobs to `${APP_DOMAIN_WITH_NGROK}/api/cron/import/bitly` after ensuring selected domains exist in the workspace.
- `GET /api/workspaces/[idOrSlug]/import/rebrandly`: Fetches Rebrandly domains (excluding `rebrand.ly`), counts their links, and returns total tags.
- `PUT /api/workspaces/[idOrSlug]/import/rebrandly`: Saves or updates the Rebrandly API key in Redis under `import:rebrandly:${workspace.id}`.
- `POST /api/workspaces/[idOrSlug]/import/rebrandly`: Verifies folder access permissions, ensures domains are added to Prisma and Vercel via `addDomainToVercel`, and triggers import cron jobs via QStash.
Sources: [apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts:14-143](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts#L14-L143), [apps/web/app/api/workspaces/idOrSlug/import/rebrandly/route.ts:14-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/rebrandly/route.ts#L14-L165)
### Cron Execution and Tag Import Workflow
The asynchronous cron route handler for Bitly processes incoming QStash payloads, enforces rate limits, handles tag imports, and delegates link migration.
```typescript
export async function POST(req: Request) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });
const body = JSON.parse(rawBody);
const { workspaceId, bitlyGroup, importTags, rateLimited = false } = body;
try {
const bitlyApiKey = await redis.get(`import:bitly:${workspaceId}`);
if (rateLimited) {
const isRateLimited = await checkIfRateLimited(bitlyApiKey, body);
if (isRateLimited) {
return NextResponse.json({
response: "rate_limited",
});
}
}
let tagsToId: Record | null = null;
if (importTags === true) {
const tagsImported = await redis.get(
`import:bitly:${workspaceId}:tags`,
);
if (!tagsImported) {
const tags = (await fetch(
`https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/tags`,
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bitlyApiKey}`,
},
},
)
.then((r) => r.json())
.then((r) => r.tags)) as string[];
await prisma.tag.createMany({
data: tags.map((tag) => ({
id: createId({ prefix: "tag_" }),
name: tag,
color: randomBadgeColor(),
projectId: workspaceId,
})),
skipDuplicates: true,
});
await redis.set(`import:bitly:${workspaceId}:tags`, "true");
}
tagsToId = await prisma.tag
.findMany({
where: {
projectId: workspaceId,
},
select: {
id: true,
name: true,
},
})
.then((tags) =>
tags.reduce((acc, tag) => {
acc[tag.name] = tag.id;
return acc;
}, {}),
);
}
await importLinksFromBitly({
...body,
tagsToId,
bitlyApiKey,
});
return NextResponse.json({
response: "success",
});
} catch (error) {
const workspace = await prisma.project.findUnique({
where: {
id: workspaceId,
},
select: {
slug: true,
},
});
throw new DubApiError({
code: "bad_request",
message: `Workspace: ${workspace?.slug || workspaceId}. Error: ${error.message}`,
});
}
} catch (error) {
await log({
message: `Error importing Bitly links: ${error.message}`,
type: "cron",
});
return handleAndReturnErrorResponse(error);
}
}
```
Sources: [apps/web/app/ee/api/cron/import/bitly/route.ts:14-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/import/bitly/route.ts#L14-L113)
> [!WARNING]
> When importing tags from Bitly groups, the system checks Redis for a cached `import:bitly:${workspaceId}:tags` flag before fetching. If tags have already been imported for the workspace, the remote fetch is skipped and existing tags are loaded from PostgreSQL into memory.
### Historical Data Backfill and Maintenance Scripts
Maintenance scripts like `backfill-reuse-commission.ts`, `fix-case-a-simple.ts`, and `fix-case-a-complex.ts` rectify affiliate link reassignments, duplicate customer event logs in Tinybird, and payout adjustments.
- `backfill-reuse-commission.ts`: Clones existing customer events from Tinybird under a newly generated duplicate customer identifier, records new click, lead, or sale events with updated link attributes, nullifies old commission event IDs, and triggers affiliate commission creation workflows.
- `fix-case-a-simple.ts`: Performs simple link and partner ID swaps for coupon code links where no paid commissions occurred via Dub. It updates non-processed commissions, marks processed commissions as pending after clearing their `payoutId`, deletes associated activity logs, and retally payouts.
- `fix-case-a-complex.ts`: Handles complex transfers where paid commissions via Dub already exist. It creates dummy overpayment commissions and corresponding clawback records to balance accounting books before recreating links for the original partner.
Sources: [apps/web/scripts/programs/backfill-reuse-commission.ts:36-349](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts#L36-L349), [apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts:10-195](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-simple.ts#L10-L195), [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts:12-272](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts#L12-L272)
## Related
- [[Partner Program Management]]
- [[Link Creation and Builder UI]]
---
## Technical docs: GET Get stablecoin payouts
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getstablecoinpayouts
## Parameters
## Responses
## Try It
---
## Technical docs: Customer Support Integrations
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/external-integrations/customer-support-integrations
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/slack/support-invite.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/app/ee/api/intercom/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/app/ee/api/intercom/webhook/process/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts)
- [apps/web/app/api/ai/support-chat/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/ai/support-chat/route.ts)
- [apps/web/app/ee/api/intercom/webhook/health-check/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/api/callback/plain/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/slack/callback/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/ui/guides/integrations.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/guides/integrations.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/lib/integrations/intercom/forward-message.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts)
- [apps/web/lib/integrations/slack/transform.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/slack/transform.ts)
- [apps/web/ui/support/chat-interface.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx)
- [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts)
- [apps/web/app/api/dub/webhook/lead-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/lead-created.ts)
- [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx)
- [apps/web/lib/integrations/slack/commands.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/slack/commands.ts)
- [apps/web/lib/ai/create-support-ticket.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts)
- [apps/web/app/ee/api/stripe/webhook/checkout-session-completed.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts)
- [apps/web/lib/plain/sync-user-plan.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts)
- [apps/web/ui/workspaces/slack-support-settings-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/integrations/integrations-layout-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/integrations/integrations-layout-client.tsx)
## Overview
Customer Support Integrations connect Dub's platform workflows directly into external support tools like Plain, Intercom, and Slack. These integrations streamline troubleshooting by synchronizing customer profiles, workspace metrics, and user plan tiers across support channels while automating ticket escalation and priority communication routing.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-272](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L272), [apps/web/app/ee/api/intercom/webhook/route.ts:1-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L1-L45), [apps/web/lib/slack/support-invite.ts:1-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L1-L200)
## Plain Customer and Workspace Context
### Overview
Dub integrates with Plain to supply support agents with dynamic customer context cards and keep user subscription plans synchronized across customer support threads. Webhook endpoints authenticate incoming Plain callback requests using the `X-Plain-Webhook-Secret` header, parse customer payloads, resolve user records, and construct rich UI cards containing workspace limits, billing tiers, and partner network statistics.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-272](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L272), [apps/web/app/api/callback/plain/workspace/route.ts:20-297](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L20-L297), [apps/web/lib/plain/sync-user-plan.ts:6-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L6-L92)
### Plan Synchronization and Customer Upsertion
The `syncUserPlanToPlain` utility handles user plan propagation whenever a workspace callback executes. It verifies email existence, upserts the Plain customer profile, extracts domain identifiers for non-generic email addresses, and queries the user's top workspace by `usageLimit`.
```typescript
export const syncUserPlanToPlain = async (user: PlainUser) => {
if (!user.email) {
console.log(`User ${user.id} has no email, skipping sync...`);
return;
}
const { data } = await upsertPlainCustomer({
id: user.id,
name: user.name,
email: user.email,
});
if (!data) {
console.log(
`Failed to upsert plain customer for user ${user.id}, skipping sync...`,
);
return;
}
const plainCustomer = data.customer;
let companyDomainName: string | undefined;
if (!isGenericEmail(user.email)) {
companyDomainName = user.email.split("@")[1];
}
...
```
Sources: [apps/web/lib/plain/sync-user-plan.ts:6-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L6-L29)
Once the top workspace is retrieved, the service assigns the customer to the `app.dub.co` customer group and updates the company tier in Plain using the workspace's plan name.
Sources: [apps/web/lib/plain/sync-user-plan.ts:57-91](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L57-L91)
> [!NOTE]
> For users with generic email domains (such as gmail.com), workspace association falls back to matching the specific `userId` rather than extracting a company domain name.
Sources: [apps/web/lib/plain/sync-user-plan.ts:27-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/plain/sync-user-plan.ts#L27-L46)
### Workspace Context Cards Execution Flow
When Plain requests context cards for a workspace support thread, the webhook processes the payload through a strict validation and lookup sequence.
1. **Authentication Check**: Verifies that `req.headers.get("X-Plain-Webhook-Secret")` matches `process.env.PLAIN_WEBHOOK_SECRET`, returning `401 Unauthorized` if invalid.
2. **Payload Parsing**: Validates the incoming body against `plainCallbackSchema`.
3. **User Resolution**: Queries `prisma.user.findUnique` using `customer.externalId` if present, or falls back to `customer.email`.
4. **Banned User Check**: If no user is found, checks `isBlacklistedEmail(customer.email)` and adds the customer to the `banned_users` group if true, returning an empty container card.
5. **Background Plan Sync**: Dispatches `waitUntil(syncUserPlanToPlain(user))` asynchronously via Vercel functions.
6. **Top Workspace Query**: Queries `prisma.project.findFirst` ordered by `usageLimit` descending to locate the user's highest-tier workspace.
Sources: [apps/web/app/api/callback/plain/workspace/route.ts:21-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L21-L89)
### Plan Badge Color Mapping
The workspace context endpoint dynamically assigns badge colors based on the user's plan configuration within the UI component renderer.
| Plan Pattern | Badge Color | Condition |
| :--- | :--- | :--- |
| `enterprise` | `RED` | `plan === "enterprise"` |
| `advanced` | `YELLOW` | `plan === "advanced"` |
| `business*` | `GREEN` | `plan.startsWith("business")` |
| `pro` | `BLUE` | `plan === "pro"` |
| Other / Free | `GREY` | Default fallback |
Sources: [apps/web/app/api/callback/plain/workspace/route.ts:157-167](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/workspace/route.ts#L157-L167)
### Partner Network Integration Cards
The partner webhook route (`/api/callback/plain/partner`) follows a parallel validation structure to render partner network statistics in Plain support sidebars. If a user lacks an `externalId`, the route searches by email, links the user ID, and upserts the Plain customer profile.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:21-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L21-L55)
The route queries `prisma.partner` linked to the user and retrieves up to 5 active programs where `totalCommissions` exceeds zero, sorted in descending order of commissions.
```typescript
const partnerProfile = await prisma.partner.findFirst({
where: {
users: {
some: {
userId: customer.externalId,
},
},
},
include: {
programs: {
select: {
program: {
select: {
name: true,
},
},
createdAt: true,
totalCommissions: true,
},
where: {
totalCommissions: {
gt: 0,
},
},
orderBy: {
totalCommissions: "desc",
},
take: 5,
},
},
});
```
Sources: [apps/web/app/api/callback/plain/partner/route.ts:57-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L57-L87)
Upon successful retrieval, the customer is added to the `partners.dub.co` customer group in Plain, and a card containing partner ID, name, email, country badge, payout status, Stripe recipient accounts, crypto wallet addresses, and program commission breakdowns is returned.
Sources: [apps/web/app/api/callback/plain/partner/route.ts:112-272](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts#L112-L272)
## Plain AI Support Ticket Creation
### Overview
The AI support ticket creation subsystem automates customer escalation routing from conversational support prompts by instantiating Plain threads through a specialized AI tool. When users request human assistance or encounter complex issues like billing disputes, account access problems, or confirmed bugs, the `createSupportTicketTool` function builds structured thread payloads and provisions tickets directly within Plain.
Sources: [apps/web/lib/ai/create-support-ticket.ts:21-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L21-L28)
### Escalation Execution Flow
The ticket creation lifecycle follows an explicit sequence of data gathering, priority computation, component assembly, and API dispatch:
1. **Context Extraction**: The execute handler unpacks `accountType`, `selectedWorkspace`, `selectedProgram`, and `chatLocation` from `globalContext`.
2. **Chat History Formatting**: Maps incoming conversation messages into text blocks, appending image count notes (`(X image attached)`) for file parts and prefixing roles as `User:` or `Dub Support:`.
3. **Priority & Metadata Resolution**: Calls `getPriorityAndMetadata()` to query workspace plans or partner lifetime payouts and determine ticket priority levels and custom metadata rows.
4. **Component Construction**: Builds Plain component arrays containing trimmed user descriptions, divider sizes (`ComponentDividerSpacingSize.M` and `ComponentDividerSpacingSize.L`), truncated chat histories (up to 5,000 characters), chat locations, and additional metadata key-value pairs.
5. **Plain Thread Dispatch**: Invokes `createPlainThread()` passing user identification details, computed priority, component structures, and optional attachment IDs.
Sources: [apps/web/lib/ai/create-support-ticket.ts:29-114](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L29-L114)
> [!NOTE]
> If workspace queries or partner payout aggregations fail inside `getPriorityAndMetadata()`, the catch block gracefully defaults ticket priority to `3` and leaves additional metadata empty rather than crashing the escalation flow.
Sources: [apps/web/lib/ai/create-support-ticket.ts:234-237](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L234-L237)
### Priority Mapping Reference
Ticket priority and metadata values are determined dynamically based on the user's active workspace plan or lifetime partner payouts.
| Account Type / Tier | Condition | Priority Level | Metadata Fields Assigned |
| :--- | :--- | :--- | :--- |
| Workspace (Enterprise / Advanced) | `workspace.plan` is `"enterprise"` or `"advanced"` | `0` | Workspace Name, Slug, Plan |
| Workspace (Business) | `workspace.plan` is `"business"` | `1` | Workspace Name, Slug, Plan |
| Workspace (Pro) | `workspace.plan` is `"pro"` | `2` | Workspace Name, Slug, Plan |
| Workspace (Default) | Other plans or unlinked workspace | `3` | None |
| Partner (Top Tier) | `partnerLifetimePayouts > 10_000_00` | `0` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Mid Tier) | `partnerLifetimePayouts > 1_000_00` | `1` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Standard Tier) | `partnerLifetimePayouts > 100_00` | `2` | Program Name, Slug, Support Email, Holding Period, Min Payout, Lifetime Payouts |
| Partner (Default) | Lifetime payouts $\le$ 100.00 or unlinked | `3` | None |
Sources: [apps/web/lib/ai/create-support-ticket.ts:141-232](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/ai/create-support-ticket.ts#L141-L232)
### Ticket Escalation UI Integration
The chat interface exposes client actions for ticket escalation via `handleEscalateViaForm`, which dispatches an automated user message (`"Please create my support ticket now."`) alongside request body metadata containing selected workspace/partner contexts, incoming Slack thread timestamps, and optional attachment IDs or ticket details.
Sources: [apps/web/ui/support/chat-interface.tsx:370-391](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx#L370-L391)
> [!WARNING]
> The chat interface sets `canEscalate` to true only when chat is enabled, message count is at least 2, status is ready, the ticket has not already been submitted, and the model has not previously requested a support ticket tool call.
Sources: [apps/web/ui/support/chat-interface.tsx:444-450](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/support/chat-interface.tsx#L444-L450)
Support chat backend routes map tool execution names to human-readable Slack tool labels using the `SLACK_TOOL_LABELS` lookup record:
```typescript
const SLACK_TOOL_LABELS: Record = {
requestSupportTicket: "Showed support ticket form",
createSupportTicket: "Created support ticket",
findRelevantDocs: "Searched documentation",
getWorkspaceDetails: "Looked up workspace details",
getProgramPerformance: "Looked up program performance",
getPlanComparison: "Compared plans",
};
```
Sources: [apps/web/app/api/ai/support-chat/route.ts:294-301](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/ai/support-chat/route.ts#L294-L301)
## Intercom Integration Setup and OAuth
### Overview
The Intercom integration installation workflow is governed by the OAuth callback handler located at `apps/web/app/ee/api/intercom/callback/route.ts`. This endpoint processes incoming authorization codes, verifies workspace permissions and plan tier capabilities, validates the connected Intercom admin profile, encrypts sensitive access tokens, and registers the installation.
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:16-126](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L16-L126)
### OAuth Callback Execution Walkthrough
When Intercom redirects back to the application, the callback route executes a precise sequence of validation and persistence checks:
1. `getSession()`: Retrieves the authenticated user session; throws a `DubApiError` with code `"unauthorized"` if no user ID is present.
2. `intercomOAuthProvider.exchangeCodeForToken(req)`: Exchanges the authorization code for an access token and extracts the target workspace context ID (`workspaceId`).
3. `prisma.project.findUniqueOrThrow()`: Queries the target workspace, verifying user membership and fetching the user role and workspace plan.
4. **Role & Plan Validation**: Checks that the user is a workspace member (`workspace.users.length > 0`), that their role is strictly `"owner"`, and that `getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations` evaluates to true — otherwise throwing `"bad_request"` or `"forbidden"` errors.
5. `prisma.integration.findUniqueOrThrow()`: Fetches the unique integration record matching `INTERCOM_INTEGRATION_ID`.
6. `new Intercom({ token: token.access_token })` & `intercom.getAdmin()`: Instantiates the Intercom client and retrieves admin details to verify the Intercom workspace `app.id_code`.
7. `intercomCredentialsSchema.parse()`: Encrypts the raw access token using `encrypt()` and bundles it with the verified `appId` according to the schema.
8. `installIntegration()`: Persists the integration installation with the encrypted credentials, followed by redirecting the user to `/${workspace.slug}/settings/integrations/intercom`.
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:34-125](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L34-L125)
> [!CAUTION]
> Only workspace members with the `"owner"` role are permitted to install advanced integrations like Intercom; non-owner member requests immediately trigger a `"bad_request"` API error response.
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:73-78](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L73-L78)
> [!WARNING]
> In development environments (`process.NODE_ENV === "development"`), requests whose host headers do not include `"localhost"` are automatically redirected to `http://localhost:8888/api/intercom/callback` preserving all query search parameters.
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:20-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L20-L27)
### Integration Credential Schema and Encryption
The credential payload validated before installation maps access tokens through cryptographic encryption and associates them with the Intercom application identifier.
| Credential Field | Transformation / Source | Purpose |
| :--- | :--- | :--- |
| `accessToken` | `encrypt(token.access_token)` | Securely encrypts the OAuth access token before database storage |
| `appId` | `admin.app?.id_code` | Stores the validated Intercom workspace identifier |
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:110-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L110-L113)
> [!NOTE]
> If the Intercom admin response lacks an `app.id_code`, the callback aborts execution and throws an internal server error stating `"Failed to retrieve Intercom workspace ID."`
Sources: [apps/web/app/ee/api/intercom/callback/route.ts:103-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts#L103-L108)
## Intercom Webhook Ingestion and Processing
### Overview
Intercom event webhooks are ingested through dedicated Next.js API route handlers that handle cryptographic signature verification, background queue dispatch via QStash, health checks, and uninstallation notifications. The webhook ingestion architecture decouples immediate HTTP response delivery from asynchronous event processing by enqueueing jobs for supported topics such as conversation admin replies.
Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:1-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L1-L45), [apps/web/app/ee/api/intercom/webhook/process/route.ts:1-127](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L1-L127)
### Webhook Event Ingestion Call Chain
When an incoming POST request arrives at `/api/intercom/webhook`, the runtime executes a strict verification and dispatch sequence:
1. `verifyIntercomWebhookSignature(req)`: Validates the request signature using the raw request headers and body.
2. `JSON.parse(rawBody)`: Parses the verified raw payload into a JavaScript object.
3. `intercomWebhookSchema.parse(body)`: Validates the payload structure against the Zod schema to extract the `topic`.
4. `relevantTopics.has(topic)`: Evaluates whether the event topic is recognized. Supported topics are restricted to `conversation.admin.replied` and `ping`.
5. `enqueueBatchJobs([...])`: Dispatches a background processing job to QStash targeting `/api/intercom/webhook/process` when the topic requires processing.
Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:9-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L9-L34)
> [!WARNING]
> If an incoming webhook event carries an unsupported topic outside of `conversation.admin.replied` or `ping`, the endpoint returns a logged response without dispatching any background jobs.
Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:19-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L19-L21)
### Webhook Processing and Lifecycle Endpoints
Asynchronous event processing and companion management routes validate QStash signatures or intercom signatures, inspect database installation records, and handle application uninstalls or health checks.
| Endpoint Route | Verification Method | Action / Purpose |
| :--- | :--- | :--- |
| `POST /api/intercom/webhook` | `verifyIntercomWebhookSignature(req)` | Ingests raw webhooks, checks topics, and enqueues batch jobs via QStash. |
| `POST /api/intercom/webhook/process` | `verifyQstashSignature(...)` | Verifies QStash signature, looks up project installations, checks program status, and dispatches `conversation.admin.replied` handlers. |
| `POST /api/intercom/webhook/health-check` | `verifyIntercomWebhookSignature(req)` | Validates installation existence and credential schema validity, returning `OK` or `UNHEALTHY` states. |
| `POST /api/intercom/webhook/uninstall` | `verifyIntercomWebhookSignature(req)` | Parses uninstallation webhooks and deletes the matching `InstalledIntegration` record from Prisma. |
Sources: [apps/web/app/ee/api/intercom/webhook/route.ts:14-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L14-L34), [apps/web/app/ee/api/intercom/webhook/process/route.ts:19-93](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L19-L93), [apps/web/app/ee/api/intercom/webhook/health-check/route.ts:16-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/health-check/route.ts#L16-L62), [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts:12-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts#L12-L41)
> [!TIP]
> During webhook processing at `/api/intercom/webhook/process`, execution verifies that the associated program is active by checking that `program.deactivatedAt` is null and that at least one partner program exists before invoking `handleConversationAdminReplied`.
Sources: [apps/web/app/ee/api/intercom/webhook/process/route.ts:67-93](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L67-L93)
### Message Logging and Error Handling
The processing route captures execution duration, request bodies, and response statuses via `captureWebhookLog()` for both successful partner discoveries and runtime errors when a `workspaceId` is available.
```typescript
if (result?.partnersFound) {
await captureWebhookLog({
workspaceId,
method: "POST",
path: "/intercom/webhook",
statusCode: 200,
duration: Date.now() - startTime,
requestBody: body,
responseBody: result.message,
userAgent: req.headers.get("user-agent"),
});
}
```
Sources: [apps/web/app/ee/api/intercom/webhook/process/route.ts:95-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/process/route.ts#L95-L106)
## Intercom Message Forwarding Architecture
### Overview
The Intercom message forwarding architecture handles bi-directional message dispatch between Dub partners, internal users, and Intercom conversations. Message dispatch is handled by two core functions: `forwardPartnerMessageToIntercom` and `forwardProgramMessageToIntercom`. Both functions parse installation credentials, decrypt the access token using `decrypt()`, instantiate the Intercom client, resolve conversation threads via Redis keys, and prepare attachments before creating or replying to conversations.
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:15-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L15-L179)
### Message Forwarding Call Chains
The message forwarding process executes through distinct sequences depending on whether the sender is a partner or an internal program admin.
For partner message forwarding, the execution follows:
`forwardPartnerMessageToIntercom()` → `intercomCredentialsSchema.parse()` → `new Intercom()` → `intercom.getOrCreateContact()` → `redis.get()` → `buildIntercomAttachments()` → (`intercom.createConversationAsContact()` or `intercom.replyAsContact()`).
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:15-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L15-L93)
For program message forwarding, the execution follows:
`forwardProgramMessageToIntercom()` → `intercomCredentialsSchema.parse()` → `new Intercom()` → `intercom.findAdminByEmail()` → `intercom.getOrCreateContact()` → `redis.get()` → `buildIntercomAttachments()` → (`intercom.createConversationAsAdmin()` or `intercom.replyAsAdmin()`).
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:95-179](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L95-L179)
> [!WARNING]
> If a partner email or a sender user email is missing during message forwarding, execution immediately returns early without making external API calls or throwing an error.
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:28-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L28-L30), [apps/web/lib/integrations/intercom/forward-message.ts:109-111](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L109-L111)
### Attachment Handling and Routing Strategy
Attachments stored in private R2 storage are prepared via `buildIntercomAttachments()`. Intercom only honors one attachment method per request: when both URLs and files are present, Intercom keeps the URLs and silently drops inline files. To prevent dropped payloads, the system evaluates whether every attachment is an image type (`attachment.type.startsWith("image/")`).
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:181-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L181-L200)
```mermaid
flowchart TD
A["buildIntercomAttachments(attachments)"] --> B{"attachments.every(isImage)"}
B -- Yes --> C["storage.getSignedDownloadUrl()"]
C --> D["Push to attachmentUrls"]
B -- No --> E["fetch(signedUrl)"]
E --> F["Buffer.toString('base64')"]
F --> G["Push to attachmentFiles"]
```
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:188-234](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L188-L234)
| Condition | Attachment Processing Action | Delivery Method |
| :--- | :--- | :--- |
| All attachments are images (`allImages === true`) | Retrieves signed download URLs expiring in 30 minutes (`expiresIn: 30 * 60`). | Pushed to `attachmentUrls` for Intercom to fetch directly. |
| Mixed attachments or non-images | Fetches signed URL content, converts to buffer, and encodes to base64. | Pushed to `attachmentFiles` containing `content_type`, `data`, and `name`. |
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:197-222](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L197-L222)
> [!TIP]
> When creating a brand-new conversation via `forwardPartnerMessageToIntercom` where non-image files are present, the initial creation call sends only `attachmentUrls`, followed immediately by `intercom.replyAsContact` to deliver the remaining base64-encoded `attachmentFiles`.
Sources: [apps/web/lib/integrations/intercom/forward-message.ts:68-78](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/intercom/forward-message.ts#L68-L78)
## Automated Slack Connect Channel Provisioning
### Overview
Automated Slack Connect channel provisioning handles the creation of dedicated customer support channels, internal support team invitations, and rate-limit enforcement for workspace support requests. The backend orchestration validates plan capabilities, ensures trial restrictions are respected, sanitizes workspace slugs for channel naming, and dispatches API calls to Slack.
Sources: [apps/web/lib/slack/support-invite.ts:1-200](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L1-L200), [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:1-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L1-L98)
### Support Invite Call-Chain Execution
The execution of a Slack support invite request flows through multiple validation, channel creation, and invitation steps across the API endpoint and library modules.
`POST /api/workspaces/[idOrSlug]/support/slack-invite` → `getPlanCapabilities()` → `isWorkspaceBillingTrialActive()` → `assertRateLimit()` (workspace policy) → `assertRateLimit()` (user policy) → `requestSlackConnectSupportInvite()` → `createSharedCustomerChannel()` → `sharedSupportChannelName()` → `slack.conversations.create()` → `sendSlackConnectInvite()` → `inviteInternalSupportMembersToChannel()` → `slack.usergroups.users.list()` → `slack.conversations.invite()` → `slack.conversations.inviteShared()`
Sources: [apps/web/lib/slack/support-invite.ts:27-156](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L27-L156), [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:26-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L26-L90)
> [!WARNING]
> Priority Slack support is restricted exclusively to active Enterprise plans. Both free trial periods and non-enterprise plans throw a `403 forbidden` DubApiError before any Slack API interactions occur.
Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:28-42](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L28-L42)
### Channel Naming and Creation Constants
The channel naming utility normalizes workspace slugs into valid Slack public channel names, enforcing length constraints and character replacements.
```typescript
export function sharedSupportChannelName({
workspaceSlug,
}: {
workspaceSlug: string;
}): string {
const base = workspaceSlug
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
const safeBase = base.length > 0 ? base : "workspace";
const prefixed = `shared-${safeBase}`;
return prefixed.length <= 80 ? prefixed : prefixed.slice(0, 80);
}
```
Sources: [apps/web/lib/slack/support-invite.ts:58-71](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L58-L71)
| Parameter / Constant | Value / Behavior | Purpose |
| :--- | :--- | :--- |
| `INTERNAL_SUPPORT_USERGROUP_ID` | `"S0AJUBR8Y1Y"` | Slack user group identifier for internal support team members. |
| Max user invite slice | `100` | Slices the usergroup list to a maximum of 100 members per invite call. |
| Max channel name length | `80` characters | Truncates prefixed channel names that exceed 80 characters. |
| Default fallback base | `"workspace"` | Used when a workspace slug results in an empty base after sanitization. |
Sources: [apps/web/lib/slack/support-invite.ts:19-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L19-L37), [apps/web/lib/slack/support-invite.ts:68-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L68-L70)
> [!NOTE]
> If `slack.conversations.create` encounters an existing channel name (`name_taken`), the function returns `{ nameTaken: true }`, which triggers a `409 conflict` error guiding the user to contact their Slack administrator.
Sources: [apps/web/lib/slack/support-invite.ts:94-99](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L94-L99), [apps/web/lib/slack/support-invite.ts:136-142](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/slack/support-invite.ts#L136-L142)
### Rate Limiting and Email Validation
Workspace support invite requests are subject to strict email validation, deduplication, and dual-layer rate limiting through Upstash policies.
```typescript
const slackSupportInviteBodySchema = z.object({
emails: z.array(z.email()).min(1).max(SLACK_SUPPORT_INVITE_MAX_EMAILS),
});
function dedupeEmails(emails: string[]): string[] {
const seen = new Set();
return emails.filter((e) => {
if (seen.has(e)) return false;
seen.add(e);
return true;
});
}
```
Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:12-23](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L12-L23)
| Rate Limit Policy Key | Identifier Scope | Target |
| :--- | :--- | :--- |
| `RATELIMIT_POLICIES.slackSupportInviteWorkspace` | Workspace ID (`workspace.id`) | Prevents excessive invites generated per workspace. |
| `RATELIMIT_POLICIES.slackSupportInviteUser` | Combined Workspace and User ID (`[workspace.id, session.user.id]`) | Limits requests per user within a given workspace. |
Sources: [apps/web/app/api/workspaces/idOrSlug/support/slack-invite/route.ts:76-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/support/slack-invite/route.ts#L76-L84)
## Slack Support UI and Lifecycle
### Client Components and Eligibility Gates
Dedicated Slack support is exposed in the Dub web application via client-side components that enforce plan capability checks, active billing trial exclusions, and workspace permission validation.
In the onboarding success page client component (`SuccessPageClient`), the visibility of the Slack invite widget is controlled by evaluating plan capabilities and ensuring active trial periods are absent:
```typescript
const showSlackInvite =
getPlanCapabilities(workspace.plan).canRequestSlackSupportInvite &&
!isWorkspaceBillingTrialActive(trialEndsAt);
```
Sources: [apps/web/app/app.dub.co/onboarding/onboarding/steps/success/page-client.tsx:53-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/success/page-client.tsx#L53-L56)
Similarly, the workspace settings card component (`SlackSupportSettingsCard`) queries workspace hooks for the current plan, user role, trial end date, and synchronization state. It evaluates permissions via `clientAccessCheck` before deciding to render:
```typescript
const permissionsError = clientAccessCheck({
action: "workspaces.write",
role,
}).error;
if (
loading ||
!slug ||
dismissed ||
permissionsError ||
!getPlanCapabilities(plan).canRequestSlackSupportInvite ||
isWorkspaceBillingTrialActive(trialEndsAt)
) {
return null;
}
```
Sources: [apps/web/ui/workspaces/slack-support-settings-card.tsx:24-38](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx#L24-L38)
> [!NOTE]
> Dismissal states for the Slack support card are persisted locally through `useSyncedLocalStorage`, bound to the specific workspace slug key `slack-support-dismissed:${slug}`.
Sources: [apps/web/ui/workspaces/slack-support-settings-card.tsx:19-22](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/workspaces/slack-support-settings-card.tsx#L19-L22)
### Admin UI Support Invite Form
Internal administrators can manage and dispatch Slack support invites directly through the admin dashboard component (`SlackSupportInvite`). The component maintains local state for handling channel name conflicts (`needsChannelId`) when a pre-existing channel name collision occurs:
```typescript
export function SlackSupportInvite() {
const [needsChannelId, setNeedsChannelId] = useState(false);
return (
);
}
```
Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:9-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L9-L51)
| Input Field Name | Element Type | Validation / Pattern | Purpose |
| :--- | :--- | :--- | :--- |
| `email` | `email` | `required`, `autoComplete="off"` | Recipient email address for the support invite. |
| `workspaceSlug` | `text` | `required`, `autoComplete="off"` | Target workspace slug prefix (`app.dub.co` domain context). |
| `channelId` | `text` | `pattern="^[CG][A-Z0-9]{8,}$"` | Optional explicit Slack channel ID requested when `nameTaken` returns true. |
Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:64-117](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L64-L117)
> [!WARNING]
> The channel ID input element enforces a strict regular expression pattern (`^[CG][A-Z0-9]{8,}$`), ensuring that administrators provide valid Slack public channel or group identifiers starting with `C` or `G`.
Sources: [apps/web/app/ee/admin.dub.co/dashboard/components/slack-support-invite.tsx:112-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/slack-support-invite.tsx#L112-L112)
## Related
- [[Partner Portal and Onboarding]]
---
## Technical docs: PATCH Update program marketplace details
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/updateprogram
## Parameters
## Request Body
Program fields to update
## Responses
## Try It
---
## Technical docs: DELETE Remove program from marketplace
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/deleteprogram
## Parameters
## Responses
## Try It
---
## Technical docs: Authentication and Sessions
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/authentication-and-sessions
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/auth/options.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts)
- [apps/web/app/api/auth/...nextauth/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/auth/%5B...nextauth%5D/route.tsx)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/app/ee/admin.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/layout.tsx)
- [apps/web/app/ee/api/admin/impersonate/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/impersonate/route.ts)
- [apps/web/lib/auth/utils.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/utils.ts)
- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/link.ts)
- [apps/web/app/api/me/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/me/route.ts)
- [apps/web/app/ee/api/email-domains/domain/verify/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/verify/route.ts)
- [apps/web/app/app.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/layout.tsx)
- [apps/web/app/ee/api/cron/domains/update/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/domains/update/route.ts)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts)
- [apps/web/app/ee/api/email-domains/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/route.ts)
- [apps/web/app/api/user/set-password/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts)
- [apps/web/lib/middleware/app.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts)
- [apps/web/ui/auth/login/login-form.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/login-form.tsx)
- [apps/web/app/ee/partners.dub.co/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/layout.tsx)
- [apps/web/lib/middleware/utils/get-user-via-token.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-user-via-token.ts)
- [apps/web/lib/auth/session.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/session.ts)
- [apps/web/lib/actions/auth/throw-if-authenticated.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/auth/throw-if-authenticated.ts)
- [apps/web/ui/account/user-id.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/account/user-id.tsx)
- [apps/web/ui/auth/login/email-sign-in.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx)
- [apps/web/app/app.dub.co/auth/auth/saml/form.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx)
- [apps/web/lib/middleware/workspaces.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts)
- [apps/web/lib/auth/admin-impersonation.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/admin-impersonation.ts)
- [apps/web/lib/middleware/admin.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts)
- [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts)
- [packages/stripe-app/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts)
- [apps/web/lib/next-auth.d.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/next-auth.d.ts)
- [apps/web/app/api/user/tokens/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/tokens/route.ts)
## Overview
Dub implements a robust, multi-layered authentication and session management architecture built on top of NextAuth.js, Prisma, and Next.js Edge Middleware. Designed to secure public API routes, user dashboards, and enterprise workspaces, the system orchestrates diverse authentication flows ranging from passwordless magic links and OAuth providers to credentials and enforced SAML Single Sign-On (SSO).
Sources: [apps/web/lib/auth/options.ts:375-390](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L375-L390), [apps/web/lib/middleware/app.ts:26-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L26-L45)
The architecture solves complex access control requirements by enforcing granular verification checks at both the application edge and server runtime layers. Key design decisions include stateless JWT session strategies with secure HTTP-only cookies, robust administrative impersonation hooks, token-based API authentication with rate limiting, and automated lifecycle management for passwords and reset tokens.
Sources: [apps/web/lib/auth/options.ts:377-390](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L377-L390), [apps/web/lib/auth/session.ts:25-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/session.ts#L25-L136), [apps/web/lib/middleware/utils/get-user-via-token.ts:5-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-user-via-token.ts#L5-L15)
## NextAuth Configuration and Providers
### Overview
Dub exposes its authentication endpoints via a public Next.js API route handler that wraps the NextAuth configuration using `NextAuth(authOptions)`, exporting both `GET` and `POST` methods to manage the complete authentication lifecycle.
Sources: [apps/web/app/api/auth/...nextauth/route.tsx:1-6](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/auth/%5B...nextauth%5D/route.tsx#L1-L6)
### Authentication Options and Custom Prisma Adapter
The NextAuth instance is configured with the `CustomPrismaAdapter(prisma)` adapter and relies on a JSON Web Token (`jwt`) session strategy. Session persistence is secured via HTTP-only cookies configured with lax same-site rules and conditional domain and security flags depending on the deployment environment.
Sources: [apps/web/lib/auth/options.ts:375-390](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L375-L390)
| Option | Value / Strategy | Purpose |
| :--- | :--- | :--- |
| `adapter` | `CustomPrismaAdapter(prisma)` | Connects NextAuth models to the underlying database via Prisma |
| `session.strategy` | `"jwt"` | Maintains stateless sessions using encrypted JSON Web Tokens |
| `cookies.sessionToken.name` | `__Secure-next-auth.session-token` (Production) / `next-auth.session-token` (Local) | Determines cookie prefix based on `VERCEL_DEPLOYMENT` |
| `cookies.sessionToken.options.httpOnly` | `true` | Prevents client-side script access to the session cookie |
| `cookies.sessionToken.options.sameSite` | `"lax"` | Mitigates CSRF vulnerabilities across cross-site requests |
| `cookies.sessionToken.options.domain` | `".dub.co"` (Production) / `undefined` (Local) | Scopes cookie domain appropriately for local development vs production |
| `pages.signIn` | `"/login"` | Custom redirect path for sign-in operations |
| `pages.error` | `"/login"` | Custom redirect path for authentication errors |
Sources: [apps/web/lib/auth/options.ts:375-394](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L375-L394)
> [!WARNING]
> When working on localhost, the cookie `domain` configuration property must be omitted entirely rather than set to `localhost`, or browser cookie validation will reject session persistence.
Sources: [apps/web/lib/auth/options.ts:385-386](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L385-L386)
### Authentication Callbacks and Event Handlers
The authentication options define comprehensive callback hooks and event triggers that execute during sign-in, token generation, and session population.
The execution flow during sign-in proceeds through specific validation steps:
1. `signIn` callback: Receives `user`, `account`, and `profile`. It checks if `user.email` is absent or blacklisted via `isBlacklistedEmail(user.email)`, returning `false` if invalid.
Sources: [apps/web/lib/auth/options.ts:395-399](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L395-L399)
2. Lockout check: If `user.lockedAt` is populated, it throws an `exceeded-login-attempts` error.
Sources: [apps/web/lib/auth/options.ts:401-403](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L401-L403)
3. Impersonation & SSO enforcement: It checks for admin impersonation (`account?.provider === "email"` via `consumeAdminImpersonation`). If not impersonating and provider is not SAML or credentials, it verifies whether SSO is enforced for the email domain via `isSamlEnforcedForEmailDomain(user.email)`, throwing `require-saml-sso` if required.
Sources: [apps/web/lib/auth/options.ts:405-420](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L405-L420)
4. Provider-specific synchronization: For Google or GitHub providers, it updates missing user names and uploads missing or non-R2 avatars to storage. For SAML or `saml-idp` providers, it extracts the target tenant workspace, validates domain matching against `ssoEmailDomain`, upserts the user into `projectUsers`, and deletes pending project invites.
Sources: [apps/web/lib/auth/options.ts:422-529](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L422-L529)
> [!TIP]
> The `jwt` callback handles session update triggers by querying Prisma for fresh user metadata (`name`, `email`, `image`, `isMachine`, `defaultWorkspace`, `defaultPartnerId`) when `trigger === "update"`.
Sources: [apps/web/lib/auth/options.ts:532-567](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L532-L567)
## Credentials, Magic Links, and SAML
### Overview
Authentication in Dub supports multiple access vectors including email verification links, traditional password credentials, and SAML Single Sign-On (SSO). The login interface coordinates these flows dynamically through `LoginForm` and provider-specific subcomponents, handling account existence checks, error code mappings, and strict security enforcement before delegating to NextAuth.
Sources: [apps/web/ui/auth/login/login-form.tsx:21-50](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/login-form.tsx#L21-L50), [apps/web/ui/auth/login/email-sign-in.tsx:13-37](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L13-L37)
### Login Execution Walkthrough
When a user initiates sign-in using the email form, the submission follows a strict asynchronous validation and dispatch sequence:
1. `onSubmit`: Intercepts form submission and prevents default browser behavior.
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:41-43](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L41-L43)
2. `checkAccountExistsAction`: If `showPasswordField` is false, it executes an asynchronous server action passing `{ email }` to query account status.
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:46-48](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L46-L48)
3. Domain / SAML verification: Evaluates `result.data`, extracting `accountExists`, `hasPassword`, and `requireSAML`. If `requireSAML` is true, it aborts and displays an error toast. If `accountExists` and `hasPassword` are both true, it sets `setShowPasswordField(true)` and returns.
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:53-66](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L53-L66)
4. Provider selection & `signIn`: If the password field is visible and populated, it selects the `"credentials"` provider; otherwise, it falls back to the magic link `"email"` provider. It then invokes NextAuth's `signIn(provider, { email, redirect: false, callbackUrl, ...password })`.
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:77-102](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L77-L102)
5. Response handling: Inspects `response.error`. If present, it maps error codes via `errorCodes` and resets `clickedMethod`. On success, it updates `setLastUsedAuthMethod("email")`. If `"email"`, it triggers a success toast; if `"credentials"`, it routes the user via `router.push`.
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:104-134](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L104-L134)
Sources: [apps/web/ui/auth/login/email-sign-in.tsx:41-135](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/email-sign-in.tsx#L41-L135)
### SAML Enforcement Checks
During sign-in, the system verifies whether corporate identity federation is mandatory for the user's email domain. The check executes via `isSamlEnforcedForEmailDomain(email)`, which inspects incoming headers for the request hostname, extracts the lowercase email domain, filters out generic email providers, and queries Prisma for a workspace matching `ssoEmailDomain` where `ssoEnforcedAt` is populated.
Sources: [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:7-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts#L7-L34), [apps/web/lib/auth/options.ts:408-420](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L408-L420)
> [!WARNING]
> If `isSamlEnforcedForEmailDomain` returns true for a user attempting standard login, the `signIn` callback throws a `require-saml-sso` error, halting standard authentication and requiring identity provider redirection.
Sources: [apps/web/lib/auth/options.ts:415-419](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L415-L419)
IdP-initiated SAML login flows are handled separately by `SAMLForm`, which extracts the authorization `code` search parameter on mount and invokes `signIn("saml-idp", { callbackUrl: "/", code })`.
Sources: [apps/web/app/app.dub.co/auth/auth/saml/form.tsx:7-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx#L7-L18)
### Login Error Codes Reference
| Error Code Key | Display Message / Action |
| :--- | :--- |
| `no-credentials` | Please provide an email and password. |
| `invalid-credentials` | Email or password is incorrect. |
| `exceeded-login-attempts` | Account has been locked due to too many login attempts. Please contact support to unlock your account. |
| `too-many-login-attempts` | Too many login attempts. Please try again later. |
| `email-not-verified` | Please verify your email address. |
| `require-saml-sso` | Your organization requires authentication through your company's identity provider. |
| `EmailSignin` | Failed to send login email. Please try again in a minute or contact support. |
| `Callback` | We encountered an issue processing your request. Please try again or contact support if the problem persists. |
| `OAuthSignin` | There was an issue signing you in. Please ensure your provider settings are correct. |
| `OAuthCallback` | We faced a problem while processing the response from the OAuth provider. Please try again. |
| `OAuthAccountNotLinked` | It looks like you already have an account with this email. Please sign in with your account email instead. |
Sources: [apps/web/ui/auth/login/login-form.tsx:31-50](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/login-form.tsx#L31-L50)
## Session Management and Route Protection
### Overview
Server-side session resolution and route protection are handled via NextAuth integration, wrapper functions, and request-level token parsing. The core session utility exposes `getSession()`, which invokes `getServerSession(authOptions)` returning a `Session` object containing user attributes such as `id`, `name`, `email`, `image`, `isMachine`, `defaultWorkspace`, and `defaultPartnerId`. Unauthenticated route access can be explicitly prohibited using `throwIfAuthenticated()`, which inspects active sessions and throws an error if an active session exists.
Sources: [apps/web/lib/auth/utils.ts:1-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/utils.ts#L1-L22), [apps/web/lib/actions/auth/throw-if-authenticated.ts:1-11](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/auth/throw-if-authenticated.ts#L1-L11)
### The `withSession` API Wrapper
The `withSession` function wraps API route handlers to enforce authentication, handle rate-limiting, and populate request context with session data.
```mermaid
sequenceDiagram
participant Client
participant withSession
participant Prisma
participant Upstash
participant Handler
Client->>withSession: HTTP Request (Cookie or Authorization header)
withSession->>withSession: Extract request headers & params
alt Authorization Header Provided
withSession->>withSession: Validate "Bearer " prefix
withSession->>withSession: Hash API token (`hashToken`)
withSession->>Prisma: Query user by token's hashedKey
Prisma-->>withSession: User record
withSession->>Upstash: Rate limit check (60 req / 1m)
Upstash-->>withSession: Limit status & headers
withSession->>Upstash: Background rate limit check for lastUsed
Upstash-->>withSession: Success boolean
opt Last used update allowed
withSession->>Prisma: Update token `lastUsed` timestamp
end
else No Authorization Header
withSession->>withSession: Call `getSession()` via NextAuth
withSession->>withSession: Validate session user ID
end
withSession->>Handler: Execute handler({ req, params, searchParams, session })
Handler-->>Client: JSON Response
```
Sources: [apps/web/lib/auth/session.ts:11-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/session.ts#L11-L136)
> [!WARNING]
> If an `Authorization` header is provided without the exact `Bearer ` prefix, `withSession` immediately throws a `bad_request` `DubApiError` rather than falling back to cookie-based session cookies.
Sources: [apps/web/lib/auth/session.ts:38-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/session.ts#L38-L47)
### Token-Based Authentication and User Info
API routes and OAuth endpoints parse authorization credentials using `getAuthTokenOrThrow(req, type)`. This helper extracts the `Authorization` header, validates its presence, and strips the specified auth type prefix (defaulting to `"Bearer"`).
Sources: [apps/web/lib/auth/utils.ts:23-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/utils.ts#L23-L38)
OAuth access tokens are validated against `RestrictedToken` database records in the user info endpoint.
```typescript
// GET /api/oauth/userinfo - get user info by access token
export async function GET(req: NextRequest) {
try {
const accessToken = getAuthTokenOrThrow(req);
const tokenRecord = await prisma.restrictedToken.findFirst({
where: {
hashedKey: await hashToken(accessToken),
expires: {
gte: new Date(),
},
installationId: {
not: null,
},
},
select: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
project: {
select: {
id: true,
name: true,
slug: true,
logo: true,
},
},
},
});
if (!tokenRecord) {
throw new DubApiError({
code: "unauthorized",
message: "Access token not found or expired.",
});
}
const { user } = tokenRecord;
const userInfo = {
id: user.id,
name: user.name,
image: user.image,
workspace: {
id: prefixWorkspaceId(tokenRecord.project.id),
slug: tokenRecord.project.slug,
name: tokenRecord.project.name,
logo: tokenRecord.project.logo,
},
};
return NextResponse.json(userInfo, {
headers: CORS_HEADERS,
});
} catch (e) {
return handleAndReturnErrorResponse(e, CORS_HEADERS);
}
}
```
Sources: [apps/web/app/api/oauth/userinfo/route.ts:16-76](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L16-L76)
### Protected API Endpoint Implementations
Protected routes leverage `withSession` to retrieve or manage user and token resources safely.
| Route File | Method | Description |
| :--- | :--- | :--- |
| `apps/web/app/api/me/route.ts` | `GET` | Fetches the complete unique user record corresponding to `session.user.id`. |
| `apps/web/app/api/user/tokens/route.ts` | `GET` | Queries all tokens belonging to `session.user.id`, sorted by `lastUsed` then `createdAt` descending. |
| `apps/web/app/api/user/tokens/route.ts` | `DELETE` | Deletes a specific token filtered by record `id` and `userId: session.user.id`. |
Sources: [apps/web/app/api/me/route.ts:1-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/me/route.ts#L1-L13), [apps/web/app/api/user/tokens/route.ts:1-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/tokens/route.ts#L1-L40)
## Edge Middleware Token Verification
### Overview
Edge-level request processing handles subdomain routing, token extraction, and workspace access control before requests reach application page handlers. The main entry point in `apps/web/middleware.ts` intercepts requests matching the configured matcher pattern—excluding API routes, Next.js internal paths, and static files—and directs traffic based on the requesting hostname.
Sources: [apps/web/middleware.ts:20-44](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L20-L44)
### Subdomain Routing and Request Pipeline
When a request arrives at the edge, `middleware()` parses the request context using `parse(req)` and routes it to specialized handlers depending on the matching hostname or path condition.
| Hostname / Path Condition | Target Middleware Handler / Action |
| :--- | :--- |
| `isAppHostname(domain)` (e.g., `app.dub.co`) | `AppMiddleware(req)` |
| `API_HOSTNAMES.has(domain)` | `ApiMiddleware(req)` |
| `path.startsWith("/stats/")` | Rewrites to `/${domain}/[key]/stats` |
| `path.startsWith("/.well-known/")` | Rewrites to `/wellknown/${domain}/${file}` |
| `domain === "dub.sh"` | Redirects via `DEFAULT_REDIRECTS[key]` |
| `ADMIN_HOSTNAMES.has(domain)` | `AdminMiddleware(req)` |
| `PARTNERS_HOSTNAMES.has(domain)` | `PartnersMiddleware(req)` |
| `isValidUrl(fullKey)` | `CreateLinkMiddleware(req)` |
| Default fallback | `LinkMiddleware(req, ev)` |
Sources: [apps/web/middleware.ts:34-89](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts#L34-L89)
### Edge Token Extraction and App Middleware Flow
For application requests targeting `app.dub.co`, `AppMiddleware` processes authentication state by invoking `getUserViaToken(req)`. This helper uses NextAuth's `getToken` with `process.env.NEXTAUTH_SECRET` to extract and return the authenticated user payload from the encrypted session cookie.
```typescript
export async function getUserViaToken(req: NextRequest) {
const session = (await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
})) as {
email?: string;
user?: UserProps;
};
return session?.user;
}
```
Sources: [apps/web/lib/middleware/utils/get-user-via-token.ts:1-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/utils/get-user-via-token.ts#L1-L15)
The execution flow within `AppMiddleware` proceeds through several distinct branches:
1. **Embed and Public Paths**: Requests matching `/embed` invoke `EmbedMiddleware(req)`, while public paths such as `/marketplace`, `/share/`, `/deeplink/`, `/unsubscribe/`, and `/auth/reset-password/` bypass authentication checks.
2. **Unauthenticated Redirects**: If no user session is found and the path is unauthenticated, requests are redirected to `/login` with a `?next=` query parameter preserving the attempted destination.
3. **Onboarding Checks**: For newly created users within the onboarding window (`ONBOARDING_WINDOW_SECONDS`) lacking a default workspace or pending invites, the middleware evaluates cached onboarding steps via `onboardingStepCache` and directs the user through setup.
4. **Root and Settings Navigation**: Standard navigation paths (`/`, `/links`, `/analytics`, `/settings`, etc.) delegate access enforcement to `WorkspacesMiddleware(req, user)`.
Sources: [apps/web/lib/middleware/app.ts:26-126](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/app.ts#L26-L126)
### Workspace Access Control and Redirection
When users access application roots or top-level settings, `WorkspacesMiddleware` resolves active workspace context and handles open-redirect protection.
```typescript
export async function WorkspacesMiddleware(req: NextRequest, user: UserProps) {
const { path, searchParamsObj, searchParamsString } = parse(req);
if (
searchParamsObj.next &&
isValidInternalRedirect({
redirectPath: searchParamsObj.next,
currentUrl: req.url,
})
) {
return NextResponse.redirect(new URL(searchParamsObj.next, req.url));
}
const defaultWorkspace = await getDefaultWorkspace(user);
if (defaultWorkspace) {
let redirectPath = path;
if (["/", "/login", "/register"].includes(path)) {
redirectPath = "";
} else if (isTopLevelSettingsRedirect(path)) {
redirectPath = `/settings/${path}`;
}
if (!redirectPath) {
const product = await getWorkspaceProduct(defaultWorkspace);
redirectPath = `/${product}`;
}
return NextResponse.redirect(
new URL(
`/${defaultWorkspace}${redirectPath}${searchParamsString}`,
req.url,
),
);
}
const projectInvite = await prismaEdge.projectInvite.findFirst({
where: { email: user.email },
select: { project: { select: { slug: true } } },
});
if (projectInvite) {
return NextResponse.redirect(
new URL(`/${projectInvite.project.slug}/invite`, req.url),
);
}
return NextResponse.redirect(new URL("/onboarding/workspace", req.url));
}
```
Sources: [apps/web/lib/middleware/workspaces.ts:10-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts#L10-L70)
> [!NOTE]
> `WorkspacesMiddleware` validates any `?next=` query parameter using `isValidInternalRedirect` prior to processing workspace lookups, preventing open-redirect vulnerabilities during session handoffs.
Sources: [apps/web/lib/middleware/workspaces.ts:13-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/workspaces.ts#L13-L22)
Client-side rendering layouts for application subdomains (`app.dub.co` and `partners.dub.co`) wrap their component trees in NextAuth's `SessionProvider` alongside React `Suspense` boundaries to maintain synchronized session state across client navigations.
Sources: [apps/web/app/app.dub.co/layout.tsx:1-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/layout.tsx#L1-L12), [apps/web/app/ee/partners.dub.co/layout.tsx:1-12](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/layout.tsx#L1-L12)
## Password Lifecycle and Resets
### Overview
The password lifecycle mechanism handles password initialization for OAuth-authenticated accounts, issues secure cryptographic reset tokens, and dispatches verification emails. This capability is exposed via the POST route handler at `/api/user/set-password`, which verifies active user sessions and guards against duplicate password provisioning.
Sources: [apps/web/app/api/user/set-password/route.ts:10-57](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts#L10-L57)
### Password Initialization and Reset Token Generation
When an OAuth-authenticated user requests to set an account password, the endpoint executes a validation check via Prisma to ensure the user exists, is not a machine account, and currently has a null password hash.
```typescript
export const POST = withSession(async ({ session }) => {
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
isMachine: false,
passwordHash: null,
},
select: {
id: true,
},
});
if (!user) {
throw new DubApiError({
code: "bad_request",
message:
"You already have a password set. You can change it in your account settings.",
});
}
const { token } = await prisma.passwordResetToken.create({
data: {
identifier: session.user.email,
token: randomBytes(32).toString("hex"),
expires: new Date(Date.now() + PASSWORD_RESET_TOKEN_EXPIRY * 1000),
},
});
```
Sources: [apps/web/app/api/user/set-password/route.ts:11-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts#L11-L37)
> [!WARNING]
> If `passwordHash` is already populated, the endpoint immediately throws a `bad_request` `DubApiError`, preventing users from overwriting existing credentials through the initial setup flow.
Sources: [apps/web/app/api/user/set-password/route.ts:23-29](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts#L23-L29)
### Email Dispatch and Development Logging
Following token persistence, the application utilizes `@dub/email` and the `ResetPasswordLink` template to dispatch delivery instructions containing the reset URL.
```typescript
// Send email with password reset link
await sendEmail({
subject: "Dub: Password reset instructions",
to: session.user.email,
react: ResetPasswordLink({
email: session.user.email,
url: `${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`,
}),
});
if (process.env.NODE_ENV === "development") {
console.info(
"Password reset URL:",
`${process.env.NEXTAUTH_URL}/auth/reset-password/${token}`,
);
}
return NextResponse.json({ ok: true });
});
```
Sources: [apps/web/app/api/user/set-password/route.ts:39-57](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts#L39-L57)
> [!TIP]
> In development environments (`NODE_ENV === "development"`), the generated password reset URL is automatically emitted to standard output via `console.info` to facilitate local testing without requiring an active email server integration.
Sources: [apps/web/app/api/user/set-password/route.ts:49-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/set-password/route.ts#L49-L54)
## Admin Impersonation and Privileged Access
### Overview
Privileged access and administrative impersonation capabilities on `admin.dub.co` are governed by specialized API endpoints, persistent tracking stores for verification tokens, and Next.js Edge middleware guards. These mechanisms allow authorized system administrators to generate secure impersonation targets, trace administrative sign-in tokens during custom adapter execution, and restrict administrative subdomains strictly to members of the designated Dub workspace.
Sources: [apps/web/app/ee/api/admin/impersonate/route.ts:1-221](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/impersonate/route.ts#L1-L221), [apps/web/lib/auth/admin-impersonation.ts:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/admin-impersonation.ts#L1-L20), [apps/web/lib/middleware/admin.ts:1-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L1-L38)
### Impersonation URL Generation and Identifier Parsing
The admin impersonation workflow begins at the POST route handler `/api/admin/impersonate`, which is protected by the `withAdmin` wrapper. The route accepts a payload containing query parameters such as an email, workspace slug, domain, or Stripe customer ID, and delegates parsing to `parseImpersonateQuery()`.
```typescript
type ImpersonateIdentifier =
| { type: "email"; email: string }
| { type: "slug"; slug: string }
| { type: "domain"; domain: string }
| { type: "stripeCustomerId"; stripeCustomerId: string };
function parseImpersonateQuery(
raw: unknown,
): ImpersonateIdentifier | { error: string } {
if (typeof raw !== "string" || !raw.trim()) {
return {
error:
"Enter a user email, workspace slug, domain, or Stripe customer ID",
};
}
let query = raw.trim();
if (query.toLowerCase().startsWith("mailto:")) {
query = query.slice(7).trim();
}
// ...
```
Sources: [apps/web/app/ee/api/admin/impersonate/route.ts:105-126](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/impersonate/route.ts#L105-L126)
Once a valid identifier type is recognized and resolved to a target user via database lookups, the route serializes the associated workspaces and programs, and calls `getImpersonateUrl(response.email)` to produce a secure session login URL.
```typescript
const data = {
email: response.email,
workspaces: await serializeWorkspaces(
response.projects.map(({ project }) => project),
),
programs:
response.partners.length > 0
? response.partners[0].partner.programs.map(({ program, ...rest }) => ({
...program,
...rest,
}))
: [],
impersonateUrl: await getImpersonateUrl(response.email),
};
return NextResponse.json(data);
});
```
Sources: [apps/web/app/ee/api/admin/impersonate/route.ts:205-221](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/impersonate/route.ts#L205-L221)
> [!NOTE]
> The admin layout wrapper explicitly provides a NextAuth `SessionProvider` context for client components operating under `admin.dub.co`.
Sources: [apps/web/app/ee/admin.dub.co/layout.tsx:1-8](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/layout.tsx#L1-L8)
### Tracking Impersonation Tokens in Adapter Hooks
To differentiate routine user sign-ins from privileged administrative impersonations, the application maintains an in-memory tracking store (`pendingAdminImpersonations`) within `admin-impersonation.ts`.
```typescript
// Tracks emails signing in via admin impersonation links. Populated in
// CustomPrismaAdapter.useVerificationToken before the token is deleted.
const pendingAdminImpersonations = new Set();
export const markAdminImpersonation = (email: string) => {
pendingAdminImpersonations.add(email.toLowerCase());
};
export const consumeAdminImpersonation = (email: string) => {
const isAdminImpersonation = pendingAdminImpersonations.has(
email.toLowerCase(),
);
if (isAdminImpersonation) {
pendingAdminImpersonations.delete(email.toLowerCase());
}
return isAdminImpersonation;
};
```
Sources: [apps/web/lib/auth/admin-impersonation.ts:1-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/admin-impersonation.ts#L1-L19)
This module exports two core utility functions: `markAdminImpersonation(email)`, which registers an email address into the active tracking set when an impersonation token is generated, and `consumeAdminImpersonation(email)`, which verifies and subsequently purges the entry when the verification token is consumed inside `CustomPrismaAdapter.useVerificationToken`.
Sources: [apps/web/lib/auth/admin-impersonation.ts:1-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/admin-impersonation.ts#L1-L19)
### Admin Middleware Guards
Requests destined for the administrative subdomain are intercepted by `AdminMiddleware`, which evaluates authentication status, workspace membership, and path permissions.
```typescript
export async function AdminMiddleware(req: NextRequest) {
const { path } = parse(req);
const user = await getUserViaToken(req);
if (!user && path !== "/login") {
return NextResponse.redirect(new URL("/login", req.url));
} else if (user) {
const isAdminUser = await prismaEdge.projectUsers.findUnique({
where: {
userId_projectId: {
userId: user.id,
projectId: DUB_WORKSPACE_ID,
},
},
});
if (!isAdminUser) {
return NextResponse.next(); // throw 404 page
} else if (
path === "/login" ||
!canAccessAdminPath({ userId: user.id, pathname: path })
) {
return NextResponse.redirect(new URL("/", req.url));
}
}
return NextResponse.rewrite(
new URL(`/admin.dub.co${path === "/" ? "" : path}`, req.url),
);
}
```
Sources: [apps/web/lib/middleware/admin.ts:8-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L8-L38)
> [!WARNING]
> If an authenticated user lacks membership in the core Dub workspace (`DUB_WORKSPACE_ID`), the middleware bypasses administrative routing and returns `NextResponse.next()`, effectively triggering a 404 page for unauthorized visitors.
Sources: [apps/web/lib/middleware/admin.ts:15-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/middleware/admin.ts#L15-L26)
## Related
- [[Routing and Multitenancy]]
- [[Enterprise SSO and SCIM]]
---
## Technical docs: POST Delete program completely
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/deleteprogramadmin
## Request Body
Program ID or slug to delete
## Responses
## Try It
---
## Technical docs: Enterprise SSO and SCIM
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/enterprise-sso-and-scim
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx)
- [apps/web/app/ee/api/scim/v2.0/...directory/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts)
- [apps/web/ui/modals/scim-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/scim-modal.tsx)
- [apps/web/lib/swr/use-scim.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-scim.ts)
- [apps/web/app/api/workspaces/idOrSlug/scim/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/scim/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/saml/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/saml.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/saml.tsx)
- [apps/web/lib/auth/options.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts)
- [apps/web/app/ee/api/auth/saml/verify/route.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/verify/route.tsx)
- [apps/web/lib/jackson.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts)
- [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx)
- [apps/web/ui/auth/login/sso-sign-in.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/sso-sign-in.tsx)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/app/app.dub.co/auth/auth/saml/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/page.tsx)
- [packages/utils/src/constants/saml.ts](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/saml.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/page-client.tsx)
- [apps/web/app/app.dub.co/auth/auth/saml/form.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx)
- [apps/web/app/ee/api/auth/saml/authorize/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/authorize/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/lib/swr/use-saml.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-saml.ts)
- [packages/stripe-app/src/views/AppSettings.tsx](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/views/AppSettings.tsx)
- [apps/web/ui/modals/saml-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/saml-modal.tsx)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/ui/modals/remove-scim-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/remove-scim-modal.tsx)
- [apps/web/app/api/callback/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/bitly/route.ts)
- [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts)
- [apps/web/app/ee/partners.dub.co/auth-login-register/generic/login/sso-login-button.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(auth-login-register)/(generic)/login/sso-login-button.tsx)
- [apps/web/lib/auth/sso-login-programs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/sso-login-programs.ts)
- [apps/web/ui/placeholders/feature-graphics/collaboration.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/placeholders/feature-graphics/collaboration.tsx)
- [apps/web/app/app.dub.co/onboarding/signed-in-hint.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/signed-in-hint.tsx)
## Overview
Dub integrates enterprise identity management capabilities through BoxyHQ Jackson integration, supporting secure SAML Single Sign-On (SSO) and SCIM 2.0 directory synchronization across enterprise workspaces. These security features allow organizations to enforce centralized authentication policies, manage automated user lifecycle provisioning, and secure team access against enterprise security requirements.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:14-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L14-L74), [apps/web/lib/jackson.ts:1-59](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts#L1-L59), [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/saml.tsx:23-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/saml.tsx#L23-L130)
## BoxyHQ Jackson Service Architecture
### BoxyHQ Jackson Service Architecture
### Overview
The enterprise authentication layer builds upon `@boxyhq/saml-jackson` to configure and instantiate controllers for SAML SSO and SCIM 2.0 provisioning. The library initializes options based on environment variables, supporting distinct development and production configurations.
Sources: [apps/web/lib/jackson.ts:1-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts#L1-L60)
### Controller Initialization and Global Caching
The `jackson()` asynchronous function acts as the central initialization and caching entry point. It verifies whether the global controller instances (`apiController`, `oauthController`, and `directorySyncController`) are attached to `globalThis`. If any controller is missing, it invokes `samlJackson(opts)` to provision them and assigns the resulting controllers to the global namespace before returning them.
```typescript
export async function jackson() {
if (
!globalThis.apiController ||
!globalThis.oauthController ||
!globalThis.directorySyncController
) {
const ret = await samlJackson(opts);
globalThis.apiController = ret.connectionAPIController;
globalThis.oauthController = ret.oauthController;
globalThis.directorySyncController = ret.directorySyncController;
}
return {
apiController: globalThis.apiController,
oauthController: globalThis.oauthController,
directorySyncController: globalThis.directorySyncController,
};
}
```
Sources: [apps/web/lib/jackson.ts:36-59](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts#L36-L59)
> [!NOTE]
> Global controller caching prevents multiple redundant initializations across serverless function warm starts and hot-reloading cycles in development.
Sources: [apps/web/lib/jackson.ts:42-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts#L42-L53)
### Jackson Configuration Options
The `opts` object configures the BoxyHQ Jackson runtime environment, adjusting paths, audience URIs, database connectivity, and client verification secrets according to `NODE_ENV`.
| Option Property | Production Value | Development Value | Purpose |
| :--- | :--- | :--- | :--- |
| `externalUrl` | `"https://api.dub.co"` | `APP_DOMAIN_WITH_NGROK` | Base external URL for metadata and endpoints |
| `samlPath` | `"/auth/saml/callback"` | `"/api/auth/saml/callback"` | Endpoint path for SAML assertion consumer service (ACS) |
| `scimPath` | `"/scim/v2.0"` | `"/api/scim/v2.0"` | Custom SCIM 2.0 endpoint path for directory sync |
| `samlAudience` | `"https://saml.dub.co"` | `"https://saml.dub.co"` | Expected SAML audience URI |
| `db.engine` | `"planetscale"` | `"planetscale"` | Database driver engine |
| `db.type` | `"mysql"` | `"mysql"` | Database type |
| `db.url` | `process.env.DATABASE_URL` | `process.env.DATABASE_URL` | Connection string for persistence |
| `db.ssl` | `{ rejectUnauthorized: false }` | `{ rejectUnauthorized: false }` | SSL configuration for database connection |
| `idpEnabled` | `true` | `true` | Enables IdP-initiated SSO |
| `clientSecretVerifier` | `process.env.NEXTAUTH_SECRET` | `process.env.NEXTAUTH_SECRET` | Secret used for client verification |
Sources: [apps/web/lib/jackson.ts:10-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jackson.ts#L10-L34)
### Supported SAML and SCIM Identity Providers
The constant `SAML_PROVIDERS` defines the supported enterprise identity providers, mapping their display names, logos, internal identifiers, copy strings, and SCIM protocol options.
| Provider Name | Identifier (`saml`) | SCIM Identifier (`scim`) | Modal Copy Keys | Work-in-Progress (`wip`) |
| :--- | :--- | :--- | :--- | :--- |
| Okta | `"okta"` | `"okta-scim-v2"` | Metadata URL, SCIM 2.0 Base URL, OAuth Bearer Token | `false` |
| Entra ID (formerly Azure AD) | `"azure"` | `"azure-scim-v2"` | App Federation Metadata URL, Tenant URL, Secret Token | `false` |
| Google | `"google"` | `"google"` | XML Metadata File, SCIM 2.0 Base URL, OAuth Bearer Token | `false` |
Sources: [packages/utils/src/constants/saml.ts:1-38](https://github.com/blade47/dub/blob/HEAD/packages/utils/src/constants/saml.ts#L1-L38)
## SAML SSO Configuration and Management
### Overview
Workspace SAML integration handles the complete lifecycle of configuring, retrieving, and removing Enterprise Single Sign-On connections via the Dub API and Jackson controller. Workspaces on the Enterprise plan with `workspaces.write` permissions can provision identity provider connections using either remote XML metadata URLs or uploaded raw XML metadata files.
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:30-97](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L30-L97), [apps/web/ui/modals/saml-modal.tsx:53-80](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/saml-modal.tsx#L53-L80)
### API Lifecycle and Request Validation
The SAML API route exposes three HTTP methods governed by workspace permissions and Zod schema validations:
| HTTP Method | Route Endpoint | Required Permissions | Required Plan | Purpose |
| :--- | :--- | :--- | :--- | :--- |
| `GET` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.read` | — | Retrieves configured SAML connections, issuer URI, and ACS callback URL |
| `POST` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.write` | `enterprise` | Provisions a new SAML connection and associates the email domain |
| `DELETE` | `/api/workspaces/[idOrSlug]/saml` | `workspaces.write` | — | Deletes SAML connections and clears workspace SSO enforcement settings |
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:30-150](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L30-L150)
> [!WARNING]
> When provisioning via `POST`, users must be authenticated with a non-generic corporate email address. Generic email domains are rejected to prevent insecure tenant-to-domain bindings.
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:61-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L61-L69)
### SAML Connection Provisioning Workflow
The connection provisioning flow executes across API validation, BoxyHQ Jackson persistence, and relational database updates in a strict sequence:
1. `createSAMLConnectionSchema.parse()` validates that either `metadataUrl` or `encodedRawMetadata` is present in the request body.
2. `isGenericEmail()` checks the session user's email domain; if the domain is generic, a `bad_request` DubApiError is thrown.
3. `jackson()` initializes and returns the BoxyHQ Jackson API controller.
4. `apiController.createSAMLConnection()` provisions the connection with parameters:
- `encodedRawMetadata`: Base64-encoded XML metadata (if uploaded)
- `metadataUrl`: Remote metadata URL
- `defaultRedirectUrl`: `${process.env.NEXTAUTH_URL}/auth/saml`
- `redirectUrl`: `process.env.NEXTAUTH_URL`
- `tenant`: `workspace.id`
- `product`: `"Dub"`
5. `prisma.project.update()` updates the workspace record with the verified `ssoEmailDomain`.
Sources: [apps/web/app/api/workspaces/idOrSlug/saml/route.ts:10-90](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/workspaces/%5BidOrSlug%5D/saml/route.ts#L10-L90)
### Client-Side State Management and Modals
The client interface relies on SWR data fetching and React component hooks to manage configuration state and modal lifecycles.
```typescript
export default function useSAML() {
const { id: workspaceId } = useWorkspace();
const { data, isLoading, mutate } = useSWR<{ connections: SAMLSSORecord[] }>(
workspaceId && `/api/workspaces/${workspaceId}/saml`,
fetcher,
{
keepPreviousData: true,
},
);
const configured = useMemo(() => {
return data?.connections && data.connections.length > 0;
}, [data]);
return {
saml: data as { connections: SAMLSSORecord[] },
provider: configured
? data!.connections[0].idpMetadata.friendlyProviderName
: null,
configured,
loading: isLoading,
mutate,
};
}
```
Sources: [apps/web/lib/swr/use-saml.ts:7-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-saml.ts#L7-L31)
The UI switches between the configuration modal (`SAMLModal`) and the removal modal (`RemoveSAMLModal`) depending on whether `configured` evaluates to true. For Google identity provider integrations, the modal renders a file upload dropzone for `.xml` metadata files which are read via `FileReader` and converted to base64 strings before submission; all other providers render a standard URL input field.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/saml.tsx:117-120](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/saml.tsx#L117-L120), [apps/web/ui/modals/saml-modal.tsx:132-205](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/saml-modal.tsx#L132-L205)
## SAML Authorization and Assertion Verification
### Overview
The SAML authorization and verification subsystem handles runtime SAML login initiation, identity provider (IdP) callback processing, assertion verification, and security enforcement through dedicated API routes and client components. It orchestrates interactions between NextAuth, Prisma, Upstash rate limiting, and BoxyHQ Jackson controllers to securely authenticate workspace users.
Sources: [apps/web/app/ee/api/auth/saml/authorize/route.ts:5-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/authorize/route.ts#L5-L25), [apps/web/app/ee/api/auth/saml/verify/route.tsx:9-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/verify/route.tsx#L9-L68), [apps/web/app/app.dub.co/auth/auth/saml/form.tsx:8-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx#L8-L21)
### Authorization and Endpoint Request Handling
The SAML authorization endpoint supports both `GET` and `POST` HTTP requests via a shared `handler` function. It retrieves query parameters or JSON payloads depending on the request method, invokes the BoxyHQ Jackson `oauthController.authorize()`, and returns either a `302` redirect or an HTML form response.
```typescript
const handler = async (req: Request) => {
const { oauthController } = await jackson();
const requestParams =
req.method === "GET" ? getSearchParams(req.url) : await req.json();
const { redirect_url, authorize_form } =
await oauthController.authorize(requestParams);
if (redirect_url) {
return NextResponse.redirect(redirect_url, {
status: 302,
});
} else {
return new Response(authorize_form, {
headers: {
"Content-Type": "text/html; charset=utf-8",
},
});
}
};
export { handler as GET, handler as POST };
```
Sources: [apps/web/app/ee/api/auth/saml/authorize/route.ts:5-27](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/authorize/route.ts#L5-L27)
### Verification and Rate Limiting
The verification endpoint (`POST /api/auth/saml/verify`) validates incoming workspace slugs, enforces rate limiting via Upstash, and verifies active SAML connections before completing identity checks.
```typescript
export async function POST(req: Request) {
const { apiController } = await jackson();
const { slug } = await req.json();
if (!slug) {
return NextResponse.json(
{ error: "No workspace slug provided." },
{ status: 400 },
);
}
try {
await assertRateLimit({
policy: RATELIMIT_POLICIES.samlVerify,
identifier: await getIP(),
});
} catch (error) {
if (error instanceof DubApiError && error.code === "rate_limit_exceeded") {
return NextResponse.json(
{
error: error.message,
},
{ status: 429 },
);
}
throw error;
}
const workspace = await prisma.project.findUnique({
where: { slug },
select: { id: true },
});
if (!workspace) {
return NextResponse.json(
{ error: "No SSO connection found for this workspace." },
{ status: 404 },
);
}
const connections = await apiController.getConnections({
tenant: workspace.id,
product: "Dub",
});
if (!connections || connections.length === 0) {
return NextResponse.json(
{ error: "No SSO connection found for this workspace." },
{ status: 404 },
);
}
const data = {
workspaceId: workspace.id,
};
return NextResponse.json({ data });
}
```
Sources: [apps/web/app/ee/api/auth/saml/verify/route.tsx:9-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/verify/route.tsx#L9-L68)
> [!WARNING]
> The verification endpoint strictly evaluates rate limit policies using `RATELIMIT_POLICIES.samlVerify` based on the requester's IP address. Exceeding this threshold immediately returns a `429` HTTP response with the corresponding error message.
Sources: [apps/web/app/ee/api/auth/saml/verify/route.tsx:22-37](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/auth/saml/verify/route.tsx#L22-L37)
### Client-Side SAML Callback Processing
The client authentication page renders an empty state with a loading spinner while `SAMLIDPForm` executes its effect hook. The form reads the authorization code from search parameters and triggers the NextAuth `saml-idp` sign-in provider.
```typescript
export default function SAMLForm() {
const searchParams = useSearchParams();
useEffect(() => {
const code = searchParams?.get("code");
signIn("saml-idp", {
callbackUrl: "/",
code,
});
}, []);
return null;
}
```
Sources: [apps/web/app/app.dub.co/auth/auth/saml/form.tsx:8-21](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/auth/saml/form.tsx#L8-L21)
## Email Domain SAML Enforcement
### Overview
Email domain SAML enforcement integrates single sign-on policy checks directly into the NextAuth credentials provider and client-side authentication flow. When users attempt to sign in using standard email and password credentials, the authentication handler checks whether SAML is enforced for their email domain. If enforced, password-based authentication is blocked, requiring the user to authenticate through their enterprise identity provider.
Sources: [apps/web/lib/auth/options.ts:256-286](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L256-L286)
### Domain-Level SAML Enforcement Check
The policy check is performed by `isSamlEnforcedForEmailDomain`, which inspects the request headers and email address. It rejects requests if the hostname is not an application hostname or if the email address belongs to a generic provider. Otherwise, it queries the database to determine whether a workspace has configured and enforced an `ssoEmailDomain`.
```typescript
export const isSamlEnforcedForEmailDomain = async (email: string) => {
const hostname = (await headers()).get("host");
const emailDomain = email.split("@")[1].toLocaleLowerCase();
if (
!hostname ||
!emailDomain ||
!isAppHostname(hostname) ||
isGenericEmail(email)
) {
return false;
}
const workspace = await prisma.project.findUnique({
where: {
ssoEmailDomain: emailDomain,
},
select: {
ssoEnforcedAt: true,
},
});
if (workspace?.ssoEnforcedAt) {
return true;
}
return false;
};
```
Sources: [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:7-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts#L7-L34)
> [!NOTE]
> Generic email providers (such as public email services handled by `isGenericEmail`) bypass domain SAML enforcement to prevent locking out individual accounts that do not belong to an enterprise domain.
Sources: [apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts:11-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workspaces/is-saml-enforced-for-email-domain.ts#L11-L18)
### Credentials Provider Guard and Error Handling
Inside the NextAuth credentials provider (`id: "credentials"`), authentication requests undergo rate limiting followed immediately by the SSO enforcement check. If `isSamlEnforcedForEmailDomain` evaluates to true, the provider throws a `require-saml-sso` error, bypassing password verification entirely.
```typescript
await assertRateLimit({
policy: RATELIMIT_POLICIES.login,
identifier: email.trim().toLowerCase(),
});
// SSO enforcement check
const ssoEnforced = await isSamlEnforcedForEmailDomain(email);
if (ssoEnforced) {
throw new Error("require-saml-sso");
}
```
Sources: [apps/web/lib/auth/options.ts:275-286](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/options.ts#L275-L286)
### Client-Side SSO Routing and Workspace Verification
The client-side `SSOSignIn` component manages workspace slug inputs and verification requests prior to initiating the NextAuth SAML sign-in flow. When submitted, it posts the workspace slug to `/api/auth/saml/verify`, retrieves the workspace identifier, and invokes `signIn("saml")` with the corresponding tenant parameters.
```typescript
export const SSOSignIn = () => {
const { isMobile } = useMediaQuery();
const {
setClickedMethod,
clickedMethod,
authMethod,
setLastUsedAuthMethod,
setShowSSOOption,
showSSOOption,
} = useContext(LoginFormContext);
return (
);
};
```
Sources: [apps/web/ui/auth/login/sso-sign-in.tsx:10-86](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/auth/login/sso-sign-in.tsx#L10-L86)
## SCIM Directory Sync Endpoint Processing
### Overview
SCIM 2.0 provisioning requests from identity providers target the dynamic API route handler at `apps/web/app/(ee)/api/scim/v2.0/[...directory]/route.ts`. The handler extracts bearer tokens from the authorization header, parses URL query filters, and passes the operation payload to the BoxyHQ Jackson `directorySyncController`.
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:13-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L13-L56)
### Request Execution Flow
When an inbound SCIM request arrives, the route executes a structured call sequence to authenticate, normalize, and dispatch the payload:
1. `headers()` and `getSearchParams(req.url)` extract the authorization secret and query parameters (`count`, `startIndex`, `filter`).
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:18-22](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L18-L22)
2. `req.json()` parses the request body, defaulting to an empty object if parsing fails.
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:25-29](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L25-L29)
3. `jackson()` initializes the core Jackson controller instance.
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:31](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L31)
4. `directorySyncController.requests.handle(request, handleEvents)` processes the resource query or mutation and triggers lifecycle event callbacks.
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:50-53](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L50-L53)
> [!NOTE]
> Resource paths containing `"Users"` map to `resourceType: "users"`, while all other path segments resolve to `"groups"`.
> Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L39)
### Directory Sync Event Handling and User Provisioning
The `handleEvents` asynchronous function receives directory synchronization events and updates workspace memberships using Prisma. Enterprise plan validation ensures events only process for active enterprise workspaces containing email attributes.
| SCIM Action / Condition | Target State | Prisma Operation | Sources |
| :--- | :--- | :--- | :--- |
| `user.created` (New user) | User invited to workspace | `inviteUser()` | [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:96-101](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L96-L101) |
| `user.updated` (`active === true` / `"True"`) | User activated | `inviteUser()` (if unassigned) | [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:104-115](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L104-L115) |
| `user.updated` (`active === false` / `"False"`) | User deactivated | `prisma.projectUsers.delete()` & `prisma.projectInvite.delete()` | [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:118-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L118-L144) |
| `user.deleted` | User deleted | `prisma.projectUsers.delete()` & `prisma.projectInvite.delete()` | [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:118-144](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L118-L144) |
Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:61-146](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L61-L146)
> [!WARNING]
> Azure AD sends boolean activation states as string values (`"True"` or `"False"`). Event guards explicitly evaluate both boolean primitives and string equivalents to prevent silent de-provisioning failures.
> Sources: [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:106-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L106-L108), [apps/web/app/ee/api/scim/v2.0/...directory/route.ts:120-122](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/scim/v2.0/%5B...directory%5D/route.ts#L120-L122)
## Enterprise SCIM UI and Lifecycle
### Overview
The client dashboard provides interface elements for configuring, inspecting, and dismantling SCIM directory synchronization. The React component tree orchestrates state across SWR hooks, workspace permission checkers, and configuration modals. Users with `workspaces.write` permissions on enterprise plans can initiate setup, retrieve directory base URLs, and securely remove synchronization configurations.
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:14-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L14-L25), [apps/web/ui/modals/scim-modal.tsx:26-86](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/scim-modal.tsx#L26-L86)
### SWR State Hook and Component Composition
The `useSCIM` hook manages data fetching against `/api/workspaces/{id}/scim` with SWR, exposing directory payloads, the configured identity provider, loading indicators, and mutation triggers. The parent `SCIM` component evaluates workspace plan limits and role-based access before rendering interactive UI controls.
| Hook / Component | File Source | Primary Responsibility |
| :--- | :--- | :--- |
| `useSCIM` | [apps/web/lib/swr/use-scim.ts:7-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-scim.ts#L7-L29) | Fetches SCIM directory records and computes `configured` state boolean. |
| `SCIM` | [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:14-181](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L14-L181) | Main dashboard settings card displaying configuration status, dropdown menus, and triggers. |
| `SCIMModal` | [apps/web/ui/modals/scim-modal.tsx:26-32](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/scim-modal.tsx#L26-L32) | Modal dialog for selecting a provider and generating directory endpoints. |
| `RemoveSCIMModal` | [apps/web/ui/modals/remove-scim-modal.tsx:15-21](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/remove-scim-modal.tsx#L15-L21) | Modal dialog requiring explicit text confirmation to revoke directory synchronization. |
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:14-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L14-L19), [apps/web/lib/swr/use-scim.ts:7-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-scim.ts#L7-L29)
> [!NOTE]
> If a workspace lacks an enterprise subscription, the configuration button displays a disabled tooltip prompting the user to upgrade or contact sales.
> Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/scim.tsx:147-162](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/scim.tsx#L147-L162)
### Configuration Modals and Directory Key Generation
When configuring a provider, the `SCIMModal` component issues an HTTP POST request to `/api/workspaces/{id}/scim` containing the selected provider slug and optional `currentDirectoryId`. Successful requests mutate the local SWR cache and display success notifications via `sonner`.
```typescript
fetch(`/api/workspaces/${id}/scim`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
provider: e.currentTarget.provider.value,
...(configured && {
currentDirectoryId: scim.directories[0].id,
}),
}),
}).then(async (res) => {
if (res.ok) {
await mutate();
toast.success("Successfully configured SCIM");
} else {
const { error } = await res.json();
toast.error(error.message);
}
setSubmitting(false);
});
```
Sources: [apps/web/ui/modals/scim-modal.tsx:94-114](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/scim-modal.tsx#L94-L114)
### Connection Revocation Lifecycle
The `RemoveSCIMModal` component governs connection teardown. To execute revocation, users must type the exact confirmation string `confirm remove scim` to unlock the danger button.
```typescript
const removeSCIM = async () => {
setRemoving(true);
if (!scim?.directories[0]) {
toast.error("No SCIM directories found");
setRemoving(false);
return;
}
const { id } = scim.directories[0];
const params = new URLSearchParams({
directoryId: id,
});
const res = await fetch(`/api/workspaces/${workspaceId}/scim?${params}`, {
method: "DELETE",
});
setRemoving(false);
if (res.ok) {
await mutate();
setShowRemoveSCIMModal(false);
toast.success("SCIM directory removed successfully");
} else {
const { error } = await res.json();
toast.error(error.message);
}
};
```
Sources: [apps/web/ui/modals/remove-scim-modal.tsx:37-61](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/remove-scim-modal.tsx#L37-L61)
## Related
- [[Authentication and Sessions]]
---
## Technical docs: GET Get recent programs
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getrecentprograms
## Parameters
## Responses
## Try It
---
## Technical docs: OAuth2 Provider and API Tokens
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/oauth2-provider-and-api-tokens
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/api/tokens/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts)
- [packages/stripe-app/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts)
- [packages/cli/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts)
- [apps/web/lib/api/oauth/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tokens/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tokens/page.tsx)
- [apps/web/app/api/oauth/token/refresh-access-token.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/refresh-access-token.ts)
- [apps/web/app/api/oauth/authorize/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts)
- [apps/web/app/api/oauth/apps/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts)
- [apps/web/lib/auth/workspace.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/workspace.ts)
- [apps/web/app/api/oauth/token/exchange-code-for-token.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts)
- [apps/web/lib/integrations/oauth-provider.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/oauth-provider.ts)
- [apps/web/app/ee/api/intercom/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/callback/route.ts)
- [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx)
- [apps/web/app/ee/api/hubspot/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/callback/route.ts)
- [apps/web/app/api/slack/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/slack/callback/route.ts)
- [apps/web/app/api/callback/bitly/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/bitly/route.ts)
- [packages/cli/src/api/callback.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/callback.ts)
- [apps/web/app/api/oauth/apps/appId/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/%5BappId%5D/route.ts)
- [apps/web/app/api/tokens/id/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts)
- [apps/web/app/api/oauth/token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/route.ts)
- [apps/web/app/app.dub.co/dashboard/account/settings/tokens/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/account/settings/tokens/page.tsx)
- [apps/web/lib/auth/token-cache.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts)
- [apps/web/app/api/user/referrals-token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts)
- [apps/web/lib/integrations/bitly/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/bitly/oauth.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/prisma/schema/oauth.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/oauth.prisma)
- [apps/web/lib/actions/generate-client-secret.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/generate-client-secret.ts)
- [apps/web/lib/integrations/google-ads/oauth.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts)
- [packages/cli/src/commands/login.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts)
## Overview
Dub provides a robust OAuth2 provider and API token management system that secures programmatic access and enables third-party application integrations across workspaces and user accounts. The platform supports standard authorization code flows with PKCE and client secrets, granular permission scopes, token caching via Upstash Redis, and dedicated endpoints for identity resolution and token lifecycles.
Sources: [apps/web/app/api/oauth/authorize/route.ts:1-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts#L1-L134), [apps/web/app/api/oauth/token/route.ts:1-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/route.ts#L1-L30), [apps/web/lib/auth/token-cache.ts:1-76](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L1-L76), [apps/web/app/api/oauth/userinfo/route.ts:1-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L1-L84)
## OAuth Application Registration and Secrets
### Overview
OAuth applications are modeled through a relational Prisma schema combining general integration metadata with OAuth-specific credentials. Each registered application is backed by an `Integration` record linked to a project workspace and user, containing descriptive properties such as names, slugs, developers, websites, install URLs, descriptions, readmes, logos, and screenshots. The associated `OAuthApp` record stores unique client identifiers, hashed client secrets, partial secret previews, redirect URIs in JSON format, and PKCE requirement flags.
Sources: [apps/web/app/api/oauth/apps/route.ts:44-118](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts#L44-L118), [apps/web/prisma/schema/oauth.prisma:1-12](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/oauth.prisma#L1-L12)
### Application Registration and Lifecycle Routes
The OAuth application lifecycle is managed via REST endpoints under `/api/oauth/apps` and `/api/oauth/apps/[appId]`, which enforce workspace permissions (`oauth_apps.read` and `oauth_apps.write`). When creating an application via `POST /api/oauth/apps`, the request body is parsed and validated against `createOAuthAppSchema`. The execution sequence proceeds as follows: `parseRequestBody()` → `createOAuthAppSchema.parseAsync()` → `prisma.integration.findUnique()` checking for slug conflicts → `createToken()` generating `clientId` and optionally `clientSecret` → `prisma.integration.create()` creating the integration and nested `oAuthApp` records → conditional storage upload for application logos via `storage.upload()`.
Sources: [apps/web/app/api/oauth/apps/route.ts:44-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts#L44-L134)
> [!WARNING]
> If a slug conflict occurs during application creation or updating, Prisma throws error code `P2002`, which is caught and re-thrown as a `DubApiError` with a `conflict` status code and a message indicating the slug is already in use.
Sources: [apps/web/app/api/oauth/apps/route.ts:67-72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts#L67-L72), [apps/web/app/api/oauth/apps/route.ts:143-155](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts#L143-L155), [apps/web/app/api/oauth/apps/appId/route.ts:153-165](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/%5BappId%5D/route.ts#L153-L165)
Updates and deletions handled by `PATCH /api/oauth/apps/[appId]` and `DELETE /api/oauth/apps/[appId]` utilize Vercel's `waitUntil` function to asynchronously clean up stale logo assets and removed screenshot URLs from object storage without blocking the HTTP response.
Sources: [apps/web/app/api/oauth/apps/appId/route.ts:52-215](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/%5BappId%5D/route.ts#L52-L215)
### Client Secret Generation and Token Prefixes
Client identifiers and secrets adhere to strict length and prefix conventions defined in the OAuth configuration constants. Client IDs use the prefix `dub_app_` with a length of 24 characters, while client secrets use the prefix `dub_app_secret_` with a length of 30 characters. When applications do not enforce PKCE (`pkce: false`), a client secret is generated upon creation.
Sources: [apps/web/lib/api/oauth/constants.ts:7-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts#L7-L14), [apps/web/app/api/oauth/apps/route.ts:74-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/apps/route.ts#L74-L84)
Developers can also regenerate client secrets out-of-band using the server action `generateClientSecret`. This action validates workspace permissions (`oauth_apps.write`), confirms ownership of the integration via `prisma.integration.findFirstOrThrow()`, generates a new token using `createToken()`, updates the database record with a newly hashed secret via `hashToken()`, and stores a masked partial secret preview displaying the last 8 characters (`dub_app_secret_****${clientSecret.slice(-8)}`).
Sources: [apps/web/lib/actions/generate-client-secret.ts:17-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/generate-client-secret.ts#L17-L51)
| Configuration Property | Value / Length | Prefix | Purpose |
| :--- | :--- | :--- | :--- |
| `CLIENT_ID` | 24 chars | `dub_app_` | Unique public identifier for the OAuth app |
| `CLIENT_SECRET` | 30 chars | `dub_app_secret_` | Confidential credential for confidential clients |
| `ACCESS_TOKEN` | 40 chars | `dub_access_token_` | Bearer token for API authentication |
| `REFRESH_TOKEN` | 40 chars | None (hashed) | Long-lived token for rotating access tokens |
| `CODE` | 40 chars | None | Short-lived authorization code |
Sources: [apps/web/lib/api/oauth/constants.ts:7-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts#L7-L16)
## Authorization Code Flow and Consent
### Overview
The authorization code flow governs how third-party applications request delegated access to user workspaces. The consent UI page (`apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx`) validates incoming query parameters against the `authorizeRequestSchema` using `validateAuthorizeRequest()`. If valid, it presents a consent screen displaying the requesting application's name, logo, developer, and requested permission scopes, along with an optional verification warning banner for unverified apps.
Sources: [apps/web/app/app.dub.co/auth/oauth/authorize/page.tsx:1-115](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx#L1-L115)
### Authorization Request Validation and Code Issuance
Approval of an authorization request is handled by the `POST /api/oauth/authorize` endpoint. The execution sequence proceeds as follows: `authorizeRequestSchema.parse()` → `getGrantedScopesForRole()` filtering requested scopes against the workspace role → `prisma.oAuthApp.findUniqueOrThrow()` fetching application metadata and integration status → plan check for restricted integrations → redirect URI validation against app whitelist → conditional PKCE presence check → `canInstallOAuthApp()` verification → `prisma.oAuthCode.create()` issuing an authorization code.
Sources: [apps/web/app/api/oauth/authorize/route.ts:17-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts#L17-L113)
> [!WARNING]
> If a request specifies Stripe or Shopify integration IDs (`STRIPE_INTEGRATION_ID`, `SHOPIFY_INTEGRATION_ID`) while the workspace is on a `free` or `pro` plan, the authorization request fails immediately with a `bad_request` error requiring a Business plan upgrade.
Sources: [apps/web/app/api/oauth/authorize/route.ts:59-70](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/authorize/route.ts#L59-L70)
### OAuth Scopes and Descriptions
The OAuth provider defines twelve distinct permission scopes that applications can request. Each scope governs specific read or write capabilities across workspace resources.
| Scope | Description |
| :--- | :--- |
| `links.read` | Read access to links |
| `links.write` | Read and Write access to links |
| `tags.read` | Read access to tags |
| `tags.write` | Read and Write access to tags |
| `analytics.read` | Read access to analytics and events |
| `domains.read` | Read access to domains |
| `domains.write` | Read and Write access to domains |
| `user.read` | Read your name, email and profile image |
| `webhooks.read` | Read access to webhooks |
| `webhooks.write` | Read and Write access to webhooks |
| `folders.read` | Read access to folders |
| `folders.write` | Read and Write access to folders |
Sources: [apps/web/lib/api/oauth/constants.ts:21-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts#L21-L50)
> [!NOTE]
> The `user.read` scope is granted by default to all authorization requests without requiring applications to explicitly request it in the authorization URL.
Sources: [apps/web/lib/api/oauth/constants.ts:33-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts#L33-L34)
## Token Exchange and Refresh Lifecycle
### Overview
The token exchange and refresh lifecycle is handled through `POST /api/oauth/token`, which parses incoming form data against `tokenGrantSchema` and branches based on the requested `grant_type`. The route supports two primary grant types: `authorization_code` and `refresh_token`, each enforcing strict client authentication, expiration checks, and token rotation rules.
Sources: [apps/web/app/api/oauth/token/route.ts:10-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/route.ts#L10-L30)
### Authorization Code Exchange
When a client presents an authorization code, `exchangeAuthCodeForToken` validates the request parameters, authenticates the client via HTTP Basic Auth or direct body parameters when PKCE is disabled, and verifies code integrity.
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:15-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L15-L112)
The execution sequence proceeds as follows: `tokenGrantSchema.parse()` → `exchangeAuthCodeForToken()` → `Promise.all([prisma.oAuthApp.findUnique(), prisma.oAuthCode.findUnique()])` fetching application configuration and code record → PKCE or client secret verification → code expiration and redirect URI comparison → `installIntegration()` provisioning workspace access → `prisma.restrictedToken.create()` persisting the access token and nested refresh token → `waitUntil()` executing deferred cleanup (`prisma.oAuthCode.delete()` and `prisma.restrictedToken.deleteMany()`).
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:15-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L15-L220)
> [!CAUTION]
> If PKCE is enabled on the `OAuthApp`, a `code_verifier` parameter is mandatory. When the code challenge method is `S256`, the verifier is hashed via `generateCodeChallengeHash()` and compared against `accessCode.codeChallenge`; a mismatch throws an `unauthorized` error with the `invalid_grant` code.
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:85-132](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L85-L132)
### Refresh Token Rotation
The `refreshAccessToken` function processes `refresh_token` grants by validating client credentials, looking up the hashed refresh token, checking expiration, and issuing a rotated token pair.
Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:13-111](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/refresh-access-token.ts#L13-L111)
The execution sequence proceeds as follows: `tokenGrantSchema.parse()` → `refreshAccessToken()` → Basic Auth header parsing if client credentials are omitted from the body → `prisma.oAuthApp.findUnique()` checking app and PKCE settings → `prisma.oAuthRefreshToken.findUnique()` querying the hashed refresh token → `prisma.installedIntegration.findUnique()` loading installation and project plan metadata → `prisma.$transaction()` deleting the old access token (`prisma.restrictedToken.delete()`) and creating the new token pair (`prisma.restrictedToken.create()`) with a fresh refresh token record.
Sources: [apps/web/app/api/oauth/token/refresh-access-token.ts:13-198](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/refresh-access-token.ts#L13-L198)
> [!IMPORTANT]
> Token lifetimes and key generation parameters are centrally defined in `OAUTH_CONFIG`. Access tokens have a lifetime of 2 hours (`7200` seconds) and a length of 40 characters with a `dub_access_token_` prefix, while refresh tokens have a lifetime of 120 days and a 40-character length without a prefix. Authorization codes expire after 2 minutes.
Sources: [apps/web/lib/api/oauth/constants.ts:2-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/oauth/constants.ts#L2-L16)
### Token Lifecycle Configuration and Trade-offs
The OAuth token storage schema coordinates relational ownership between applications, authorization codes, restricted tokens, and refresh tokens across database tables.
| Model | Primary Key | Unique Constraints | Cascade Relations |
| :--- | :--- | :--- | :--- |
| `OAuthApp` | `id` | `integrationId`, `clientId` | `oAuthCodes`, `integration` |
| `OAuthCode` | `id` (cuid) | `code` | `oAuthApp`, `user`, `project` |
| `OAuthRefreshToken` | `id` (cuid) | `hashedRefreshToken` | `accessToken`, `installedIntegration` |
Sources: [apps/web/prisma/schema/oauth.prisma:1-49](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/oauth.prisma#L1-L49)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Hashed token storage (`hashedKey`, `hashedRefreshToken`) | Prevents plain-text token exposure if the database is compromised | Requires hashing overhead on every token exchange and verification lookup |
| Single token per client per user per workspace (`deleteMany` in `waitUntil`) | Prevents accumulation of orphaned active tokens per installation | Automatically revokes concurrent active sessions for the same integration |
| Deferred cleanup via Vercel `waitUntil` | Speeds up the token response by non-blocking code deletion | Relies on serverless runtime background execution support |
Sources: [apps/web/app/api/oauth/token/exchange-code-for-token.ts:201-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/token/exchange-code-for-token.ts#L201-L220)
## UserInfo Endpoint and Identity Resolution
### Overview
The UserInfo endpoint (`GET /api/oauth/userinfo`) resolves the authenticated user and associated workspace identity from a bearer token. It implements CORS support via predefined headers (`Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, OPTIONS`, and `Access-Control-Allow-Headers: Content-Type, Authorization`) and handles preflight `OPTIONS` requests by returning status `204`.
Sources: [apps/web/app/api/oauth/userinfo/route.ts:7-14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L7-L14), [apps/web/app/api/oauth/userinfo/route.ts:78-83](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L78-L83)
### Identity Resolution Call Chain
The execution sequence proceeds as follows: `GET()` → `getAuthTokenOrThrow(req)` extracts the bearer token from the request → `hashToken(accessToken)` hashes the token string → `prisma.restrictedToken.findFirst()` queries the database for an active, non-expired token matching the hash where `installationId` is not null → selection extracts the related `user` (`id`, `name`, `image`) and `project` (`id`, `name`, `slug`, `logo`) records → `prefixWorkspaceId(tokenRecord.project.id)` formats the workspace identifier → `NextResponse.json(userInfo)` serializes the payload.
Sources: [apps/web/app/api/oauth/userinfo/route.ts:16-72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L16-L72)
> [!WARNING]
> If `tokenRecord` is not found or has expired (`expires < new Date()`), or lacks an `installationId`, the lookup fails and throws a `DubApiError` with code `unauthorized` and message `"Access token not found or expired."`.
Sources: [apps/web/app/api/oauth/userinfo/route.ts:20-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/oauth/userinfo/route.ts#L20-L54)
## Personal and Workspace API Tokens
### Overview
Dub supports scoped personal and workspace API tokens used by external applications and automation clients to interact with workspace resources. Tokens are managed through standard REST API endpoints supporting retrieval, creation, updating, and revocation, alongside dashboard client components. Workspace tokens enforce limits, role validation, and optional machine-user provisioning.
Sources: [apps/web/app/api/tokens/route.ts:25-184](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L25-L184), [apps/web/app/api/tokens/id/route.ts:11-150](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L11-L150)
### Token Management Endpoints
The API provides structured operations for inspecting, modifying, and deleting tokens within a workspace context. Each route enforces required permissions via workspace authorization middleware.
| HTTP Method | Path | Required Permission | Description |
| :--- | :--- | :--- | :--- |
| `GET` | `/api/tokens` | `tokens.read` | Lists all non-installation workspace tokens, ordered by last used and creation date. |
| `POST` | `/api/tokens` | `tokens.write` | Generates a new API token, enforcing workspace limits and scope validations. |
| `GET` | `/api/tokens/:id` | `tokens.read` | Retrieves details for a specific token ID within the workspace. |
| `PATCH` | `/api/tokens/:id` | `tokens.write` | Updates a token's name or scopes, refreshing the token cache. |
| `DELETE` | `/api/tokens/:id` | `tokens.write` | Deletes a token, purging associated machine users and cache entries. |
Sources: [apps/web/app/api/tokens/route.ts:26-65](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L26-L65), [apps/web/app/api/tokens/route.ts:68-184](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L68-L184), [apps/web/app/api/tokens/id/route.ts:12-150](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L12-L150)
> [!WARNING]
> Workspace token creation is capped at a maximum of `100` active tokens (`MAX_WORKSPACE_TOKENS`). Exceeding this limit throws a forbidden `DubApiError` prompting users to contact support.
Sources: [apps/web/app/api/tokens/route.ts:19-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L19-L20), [apps/web/app/api/tokens/route.ts:110-115](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L110-L115)
### Token Creation and Machine User Call Chain
The token creation workflow (`POST /api/tokens`) executes the following sequential stages: `POST()` → `assertRateLimit()` checks creation frequency against `RATELIMIT_POLICIES.createToken` → `createTokenSchema.parse()` validates request body parameters (`name`, `isMachine`, `scopes`) → workspace user role validation confirms authorization → `hashToken()` generates a secure hash of the raw `dub_{nanoid(24)}` token → prisma transaction counts existing tokens against `MAX_WORKSPACE_TOKENS` (using isolation level `ReadUncommitted`) → if `isMachine` is true, a dedicated machine user is created with a `user_` prefix and associated as a workspace member → `prisma.restrictedToken.create()` inserts the token record with hashed key, partial display key, and space-separated scopes → `waitUntil()` dispatches an email notification via `sendEmail()` with the `APIKeyCreated` template → `NextResponse.json()` returns the plain text token once.
Sources: [apps/web/app/api/tokens/route.ts:68-180](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L68-L180)
> [!NOTE]
> Only workspace owners can create machine users (`isMachine: true`). Attempting to create a machine user with a non-owner role throws a forbidden `DubApiError`.
Sources: [apps/web/app/api/tokens/route.ts:81-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/route.ts#L81-L87)
## Scope Enforcement and Token Caching
### Overview
Workspace authentication middleware authenticates incoming requests by inspecting `Authorization` Bearer tokens, checking Upstash Redis cache via hashed keys, querying Prisma for restricted or legacy tokens, validating expiration, enforcing plan-based rate limits, and updating token last-used timestamps asynchronously.
Sources: [apps/web/lib/auth/workspace.ts:98-302](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/workspace.ts#L98-L302), [apps/web/lib/auth/token-cache.ts:32-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L32-L74)
### Authentication and Caching Mechanism
The authentication flow executes the following call chain: `withWorkspace()` wrapper executes → clones incoming request via `req.clone()` → extracts `Authorization` header → checks `Bearer ` prefix via `authorizationHeader.startsWith("Bearer ")` → extracts raw API key → computes `hashedKey = await hashToken(apiKey)` → queries Redis cache via `tokenCache.get({ hashedKey })` → if cache misses, queries database via `prisma.restrictedToken.findUnique()` for restricted tokens (`dub_` prefix) or `prisma.token.findUnique()` for legacy tokens → validates token existence and `token.user` presence → checks `token.expires < new Date()` for expiration → if cache missed, persists item via background `waitUntil(tokenCache.set({ hashedKey, token }))` → evaluates plan rate limits via `rateLimitRequest()` using identifier `workspace:ratelimit:${hashedKey}` → updates token `lastUsed` timestamp asynchronously via `ratelimit(1, "1 m").limit()` and database update → populates `session` with user metadata.
Sources: [apps/web/lib/auth/workspace.ts:81-302](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/workspace.ts#L81-L302), [apps/web/lib/auth/token-cache.ts:33-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L33-L51)
> [!WARNING]
> Requests lacking the `Bearer ` prefix on their `Authorization` header immediately throw a `bad_request` `DubApiError` requiring the correct schema prefix.
Sources: [apps/web/lib/auth/workspace.ts:98-106](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/workspace.ts#L98-L106)
### Token Cache and Upstash Redis Integration
The `TokenCache` class interfaces with Upstash Redis (`redis`) using the prefix `dubTokenCache` and a default 24-hour expiration (`CACHE_EXPIRATION`). Cached items adhere to a Zod schema validating user details, scopes, and project plans.
Sources: [apps/web/lib/auth/token-cache.ts:1-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L1-L74)
| Method | Parameters | Redis Operation | Description |
| :--- | :--- | :--- | :--- |
| `set` | `{ hashedKey, token }` | `redis.set` with `ex: 86400` | Serializes and caches token metadata for 24 hours. |
| `get` | `{ hashedKey }` | `redis.get` | Retrieves cached `TokenCacheItem` using the prefixed key. |
| `delete` | `{ hashedKey }` | `redis.del` | Removes a token cache entry upon revocation or deletion. |
| `expireMany` | `{ hashedKeys }` | `redis.pipeline()` / `expire(..., 1)` | Instantly expires multiple cache keys using a Redis pipeline. |
Sources: [apps/web/lib/auth/token-cache.ts:33-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/token-cache.ts#L33-L69)
> [!NOTE]
> When a token is updated via `PATCH /api/tokens/:id` or deleted via `DELETE /api/tokens/:id`, the token cache is explicitly synchronized using `waitUntil(tokenCache.set(...))` or `waitUntil(tokenCache.delete(...))`.
Sources: [apps/web/app/api/tokens/id/route.ts:93-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L93-L100), [apps/web/app/api/tokens/id/route.ts:136-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L136-L141)
### Scope Enforcement and Role Validation
Scope validation ensures that users and tokens possess appropriate permissions before executing workspace operations. Role-based scope checks are enforced during token updates and workspace route handlers.
Sources: [apps/web/app/api/tokens/id/route.ts:48-77](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L48-L77)
> [!CAUTION]
> The `PATCH /api/tokens/:id` route validates requested scopes against the user's project role via `validateScopesForRole(scopes, role)`. If any requested scope is unavailable for that role, an `unprocessable_entity` `DubApiError` is thrown.
Sources: [apps/web/app/api/tokens/id/route.ts:54-77](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/tokens/%5Bid%5D/route.ts#L54-L77)
## First-Party Client Integrations
### Overview
First-party client integrations and third-party service connections within Dub rely on specialized OAuth provider abstractions and client helper utilities. The architecture standardizes OAuth interactions across CLI commands, Stripe App extensions, and webhook integrations like Bitly, Slack, Intercom, HubSpot, and Google Ads.
Sources: [packages/stripe-app/src/utils/oauth.ts:1-150](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts#L1-L150), [packages/cli/src/utils/oauth.ts:1-9](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L1-L9), [apps/web/lib/integrations/oauth-provider.ts:1-174](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/oauth-provider.ts#L1-L174)
### Third-Party Provider Abstractions
The `OAuthProvider` class handles authorization URL generation, state storage in Upstash Redis with a 30-minute expiration, and code exchange using configurable body formats and authorization methods.
Sources: [apps/web/lib/integrations/oauth-provider.ts:25-141](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/oauth-provider.ts#L25-L141)
| Configuration Property | Type | Description |
| :--- | :--- | :--- |
| `name` | `string` | Human-readable name of the OAuth provider. |
| `clientId` | `string` | Client identifier assigned by the provider. |
| `clientSecret` | `string` | Client secret used for token exchanges. |
| `authUrl` | `string` | Provider authorization endpoint URL. |
| `tokenUrl` | `string` | Provider token exchange endpoint URL. |
| `redirectUri` | `string` | Callback URI where the provider redirects after authorization. |
| `scopes` | `string` (optional) | Space-separated list of requested OAuth scopes. |
| `redisStatePrefix` | `string` | Prefix key for storing transient state tokens in Upstash Redis. |
| `tokenSchema` | `z.ZodSchema` | Zod schema for validating token responses. |
| `bodyFormat` | `"form" \| "json"` | Request body serialization format for token requests. |
| `responseFormat` | `"json" \| "text"` (optional) | Expected response body format from the token endpoint. |
| `authorizationMethod` | `"header" \| "body"` | Method for passing client credentials during token exchange. |
Sources: [apps/web/lib/integrations/oauth-provider.ts:5-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/oauth-provider.ts#L5-L18)
> [!NOTE]
> Concrete implementations extend `OAuthProvider` to customize parameters, such as `googleAdsOAuthProvider`, which configures offline access and custom scopes.
Sources: [apps/web/lib/integrations/google-ads/oauth.ts:11-38](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L11-L38), [apps/web/lib/integrations/google-ads/oauth.ts:222-233](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/google-ads/oauth.ts#L222-L233)
### CLI Login and Local Callback Server
The CLI authentication lifecycle initiates via the `login` command, which generates a PKCE code verifier (`getNanoid(64)`), constructs the authorization URI using `@badgateway/oauth2-client`, opens the browser via `open(authUrl)`, and spawns a local HTTP server on port 4587 to capture the callback.
Sources: [packages/cli/src/commands/login.ts:9-38](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L9-L38)
The call chain for the CLI callback handling executes: `oauthCallbackServer()` creates an HTTP server listening on port 4587 (`server.listen(4587)`) with a 5-minute timeout (`setTimeout(..., 300000)`) → incoming requests are parsed via `url.parse(req.url || "", true)` → verifies `reqUrl.pathname === "/callback"` and `req.method === "GET"` → extracts the authorization `code` query parameter → invokes `oauthClient.authorizationCode.getToken({ code, redirectUri, codeVerifier })` to exchange credentials → invokes `setConfig(configInfo)` to persist tokens and domain (`dub.sh`) → terminates the server via `server.close()` and exits the process via `process.exit(0)`.
Sources: [packages/cli/src/api/callback.ts:17-86](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/callback.ts#L17-L86)
> [!WARNING]
> If the callback server does not receive an authorization code within 300,000 milliseconds (5 minutes), the timeout handler automatically closes the server and terminates the process.
Sources: [packages/cli/src/api/callback.ts:80-84](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/callback.ts#L80-L84)
### Stripe App OAuth Utilities
The Stripe App integration manages Dub authentication inside Stripe dashboards using mode-specific redirect URLs and secure secret storage.
Sources: [packages/stripe-app/src/utils/oauth.ts:1-150](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts#L1-L150)
```typescript
export async function getValidToken({ stripe }: { stripe: Stripe }) {
const token = await getSecret({
stripe,
name: "dub_token",
});
if (!token) {
throw new Error("Access token not found for the account.");
}
try {
await getUserInfo({ token });
} catch (e) {
const refreshedToken = await refreshToken({ token });
if (!refreshedToken) {
console.error("Failed to refresh access token.");
return null;
}
await setSecret({
stripe,
name: "dub_token",
payload: JSON.stringify(refreshedToken),
});
return refreshedToken;
}
return token;
}
```
Sources: [packages/stripe-app/src/utils/oauth.ts:91-121](https://github.com/blade47/dub/blob/HEAD/packages/stripe-app/src/utils/oauth.ts#L91-L121)
## Related
- [[Authentication and Sessions]]
- [[OpenAPI and Public REST API]]
---
## Technical docs: GET List marketplace programs
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getprograms
## Responses
## Try It
---
## Technical docs: Identity Verification
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/identity-verification
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/actions/partners/start-identity-verification.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx)
- [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx)
- [apps/web/app/api/callback/plain/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/partner/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/cron/partners/verify-country-change/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/verify-country-change/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/embed/referrals/tremendous/send-otp/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/tremendous/send-otp/route.ts)
- [apps/web/app/ee/api/embed/referrals/tremendous/verify-otp/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/tremendous/verify-otp/route.ts)
- [apps/web/ui/partners/identity-verification/identity-verification-card.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-card.tsx)
- [apps/web/lib/veriff/create-veriff-session.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/create-veriff-session.ts)
- [packages/email/src/templates/broadcasts/identity-verification-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/identity-verification-announcement.tsx)
- [apps/web/lib/actions/partners/start-partner-platform-verification.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-partner-platform-verification.ts)
- [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts)
- [apps/web/app/ee/app.dub.co/embed/referrals/quickstart.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/quickstart.tsx)
- [apps/web/app/api/user/referrals-token/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/user/referrals-token/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/veriff/client.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts)
- [apps/web/lib/auth/track-dub-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/track-dub-lead.ts)
- [apps/web/app/api/veriff/webhook/handle-decision-event.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts)
- [apps/web/ui/modals/social-verification-by-code-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/social-verification-by-code-modal.tsx)
- [apps/web/lib/partners/sync-partner-identity.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/sync-partner-identity.ts)
- [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts)
- [apps/web/lib/auth/partner.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/auth/partner.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/installation-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/installation-section.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/verify-install.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/verify-install.tsx)
- [apps/web/app/ee/partners.dub.co/onboarding/onboarding/payouts/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(onboarding)/onboarding/payouts/page.tsx)
- [apps/web/app/ee/app.dub.co/embed/referrals/token.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/app.dub.co/embed/referrals/token.tsx)
## Overview
Identity verification in Dub utilizes the Veriff platform to authenticate partner identities, safeguard the partner network against duplicate identity fraud, and build trust with program owners. The verification system encompasses API client integrations, automated webhook decision handlers, cron verification routines, and specialized user interface components across both partner and administrative portals.
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:1-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L1-L89), [apps/web/lib/veriff/client.ts:1-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L1-L47), [apps/web/app/api/veriff/webhook/handle-decision-event.ts:1-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L1-L141)
## Veriff Integration Client and Configuration
### Overview
The Veriff integration layer provides low-level connectivity to the Veriff Station API (`https://stationapi.veriff.com/v1`), managing authenticated HTTP requests, HMAC signature generation for decision retrieval, and session initialization. The architecture inherits from an underlying `HttpBaseClient` and enforces runtime environment assertions via `assertEnv` to guarantee required credentials are present.
Sources: [apps/web/lib/veriff/client.ts:1-47](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L1-L47)
### Client Implementation and Environment Configuration
The `VeriffClient` class configures base connectivity settings, including a vendor identifier, the API base URL, and response logging behavior. Authentication headers are dynamically constructed by asserting the presence of `VERIFF_API_KEY`.
```typescript
class VeriffClient extends HttpBaseClient {
protected readonly vendor = "Veriff";
protected readonly baseUrl = "https://stationapi.veriff.com/v1";
protected readonly logResponseBodies = false;
protected buildAuthHeaders() {
return {
"X-AUTH-CLIENT": assertEnv("VERIFF_API_KEY"),
};
}
}
```
Sources: [apps/web/lib/veriff/client.ts:11-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L11-L20)
> [!IMPORTANT]
> Both `VERIFF_API_KEY` and `VERIFF_SHARED_SECRET` are asserted at runtime via `assertEnv()`. Omitting these environment variables will cause immediate failure during header generation or cryptographic signing.
Sources: [apps/web/lib/veriff/client.ts:1-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L1-L44)
### Session Initialization and Decision Fetching
The client exposes methods for creating verification sessions and retrieving session decisions. The `fetchSessionDecision` method computes an `X-HMAC-SIGNATURE` header using SHA-256 over the target `sessionId` with the `VERIFF_SHARED_SECRET`.
```typescript
async createSession(input: z.input) {
return await this.post("/sessions", {
input,
inputSchema: veriffCreateSessionInputSchema,
outputSchema: veriffCreateSessionOutputSchema,
});
}
async fetchSessionDecision(sessionId: string) {
const hmacSignature = crypto
.createHmac("sha256", assertEnv("VERIFF_SHARED_SECRET"))
.update(sessionId)
.digest("hex");
return await this.get(`/sessions/${sessionId}/decision`, {
headers: {
"X-HMAC-SIGNATURE": hmacSignature,
},
outputSchema: veriffDecisionEventSchema,
});
}
```
Sources: [apps/web/lib/veriff/client.ts:23-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L23-L44)
The high-level wrapper function `createVeriffSession` processes partner records by splitting full names into given and family name parts, handling edge cases where only a single name token exists, and invoking `veriffClient.createSession`.
```typescript
export async function createVeriffSession({
partner,
}: {
partner: Pick;
}) {
const nameParts = partner.name.split(" ");
const firstName = nameParts[0] || partner.name;
const lastName = nameParts.slice(1).join(" ") || partner.name;
try {
return await veriffClient.createSession({
verification: {
vendorData: partner.id,
person: {
firstName,
lastName,
},
},
});
} catch (error) {
throw new Error(
"Failed to create Veriff session. Please try again later or contact support.",
);
}
}
```
Sources: [apps/web/lib/veriff/create-veriff-session.ts:4-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/create-veriff-session.ts#L4-L28)
### Call-Chain Execution Walkthrough
When initiating a partner verification session, execution flows sequentially through client parsing, HTTP transport, and remote API ingestion:
1. `createVeriffSession()` receives the partner object, splits `partner.name` by whitespace to isolate `firstName` and `lastName`, and constructs the payload.
2. `veriffClient.createSession()` accepts the Zod-validated input and delegates to `this.post("/sessions")` on the `HttpBaseClient`.
3. `buildAuthHeaders()` injects the `X-AUTH-CLIENT` header by evaluating `assertEnv("VERIFF_API_KEY")`.
4. The Station API returns the session response conforming to `veriffCreateSessionOutputSchema`.
Sources: [apps/web/lib/veriff/create-veriff-session.ts:4-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/create-veriff-session.ts#L4-L22), [apps/web/lib/veriff/client.ts:16-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L16-L29)
### Veriff Client Configuration Reference
| Property / Method | Target / Type | Purpose | Sources |
| :--- | :--- | :--- | :--- |
| `vendor` | `"Veriff"` | Identifies the client vendor in base logs and errors. | [apps/web/lib/veriff/client.ts:12-12](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L12-L12) |
| `baseUrl` | `"https://stationapi.veriff.com/v1"` | Root endpoint for all Veriff Station API requests. | [apps/web/lib/veriff/client.ts:13-13](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L13-L13) |
| `logResponseBodies` | `boolean` (`false`) | Controls whether raw HTTP response bodies are dumped to logs. | [apps/web/lib/veriff/client.ts:14-14](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L14-L14) |
| `buildAuthHeaders()` | Method | Asserts and returns the `X-AUTH-CLIENT` header mapping to `VERIFF_API_KEY`. | [apps/web/lib/veriff/client.ts:16-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L16-L20) |
| `createSession()` | Method | Dispatches a POST request to `/sessions` with input/output validation schemas. | [apps/web/lib/veriff/client.ts:23-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L23-L29) |
| `fetchSessionDecision()` | Method | Computes SHA-256 HMAC signature and fetches session decisions via GET `/sessions/{sessionId}/decision`. | [apps/web/lib/veriff/client.ts:32-44](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L32-L44) |
Sources: [apps/web/lib/veriff/client.ts:11-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/veriff/client.ts#L11-L45)
## Initiating Identity Verification Flows
### Overview
The platform provides two distinct mechanisms for initiating Veriff identity verification sessions: a user-facing Server Action (`startIdentityVerificationAction`) and an administrative API route (`POST /api/admin/partners/[partnerId]/generate-veriff-session`). Both entry points enforce state checks, validate existing session expiration, and interact with the database using Prisma.
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:16-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L16-L89), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L12-L89)
### Server Action and Admin Endpoint Implementation
The user-facing server action utilizes `authPartnerActionClient.action` and requires the executing partner user to possess the `partner_profile.update` permission via `throwIfNoPermission`. Conversely, the administrative route uses the `withAdmin` middleware, restricting execution exclusively to users with the `owner` role.
```typescript
export const startIdentityVerificationAction = authPartnerActionClient.action(
async ({ ctx }) => {
const { partner, partnerUser } = ctx;
throwIfNoPermission({
role: partnerUser.role,
permission: "partner_profile.update",
});
if (partner.identityVerificationStatus) {
switch (partner.identityVerificationStatus) {
case "approved":
throw new Error(
"Your identity has already been verified. No further action is required.",
);
case "submitted":
case "review":
throw new Error(
"A verification attempt is already in progress. Please wait for it to complete or resubmit.",
);
}
}
// ...
},
);
```
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:16-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L16-L37), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L12-L48)
> [!WARNING]
> Attempting to generate a session when `identityVerificationStatus` is `approved`, `submitted`, or `review` will immediately abort execution, throwing a validation error or returning a `400` HTTP response depending on whether the server action or admin route was invoked.
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:25-37](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L25-L37), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:34-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L34-L48)
### Session Reuse and Rate Limiting
To prevent duplicate active sessions and manage external API consumption, both flows check existing metadata before provisioning a new Veriff session. If an unexpired session is found, its existing URL is returned directly.
```typescript
const veriffMetadata = parseVeriffMetadata(partner.veriffMetadata);
if (
veriffMetadata.attemptCount >= MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS
) {
throw new Error(
"You've reached the maximum number of identity verification attempts. Please contact support: https://dub.co/support",
);
}
if (
partner.veriffSessionId &&
veriffMetadata.sessionUrl &&
veriffMetadata.sessionExpiresAt &&
veriffMetadata.sessionExpiresAt > new Date()
) {
return {
sessionUrl: veriffMetadata.sessionUrl,
};
}
await assertRateLimit({
policy: RATELIMIT_POLICIES.identityVerificationStart,
identifier: partner.id,
});
```
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:39-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L39-L65), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:50-64](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L50-L64)
> [!NOTE]
> The server action enforces Upstash rate limiting using `RATELIMIT_POLICIES.identityVerificationStart` keyed on the partner ID, whereas the admin endpoint relies strictly on the `owner` role authorization wrapper.
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:62-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L62-L65), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:86-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L86-L89)
### Call-Chain Execution Walkthrough
When a session cannot be reused and passes rate limits, execution proceeds through creation and database persistence:
1. `startIdentityVerificationAction()` or `POST` route verifies authorization and parses partner metadata via `parseVeriffMetadata()`.
2. `assertRateLimit()` ensures the partner has not exceeded initiation quotas.
3. `createVeriffSession()` is called with the partner record to obtain a new verification object containing `verification.id` and `verification.url`.
4. `prisma.partner.update()` persists the new session ID and merges metadata containing a 7-day expiration calculated via `addDays(new Date(), 7)`.
5. The session URL is returned to the client.
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:39-87](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L39-L87), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:50-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L50-L84)
### Verification Flow Parameters and Methods
| Flow / Handler | Authorization Requirement | Rate Limit Policy | Success Return Value | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `startIdentityVerificationAction` | `partner_profile.update` permission | `RATELIMIT_POLICIES.identityVerificationStart` | `{ sessionUrl: string }` | [apps/web/lib/actions/partners/start-identity-verification.ts:16-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L16-L88) |
| `POST /api/admin/partners/[partnerId]/generate-veriff-session` | Admin `owner` role via `withAdmin` | None (Admin-bypassed) | `NextResponse.json({ sessionUrl: string })` | [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L12-L89) |
Sources: [apps/web/lib/actions/partners/start-identity-verification.ts:16-88](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/actions/partners/start-identity-verification.ts#L16-L88), [apps/web/app/ee/api/admin/partners/partnerId/generate-veriff-session/route.ts:12-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/admin/partners/%5BpartnerId%5D/generate-veriff-session/route.ts#L12-L89)
## Veriff Webhook and Decision Processing
### Overview
The verification subsystem processes incoming Veriff decision webhooks and handles asynchronous background cron verifications for partner country changes. When Veriff posts a decision event or a cron job executes, the system resolves partner sessions, maps raw verification statuses to Prisma enums, checks for duplicate identity fraud and country mismatches, updates metadata, and dispatches transactional emails with idempotency keys.
Sources: [apps/web/app/ee/api/cron/partners/verify-country-change/route.ts:21-140](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/verify-country-change/route.ts#L21-L140), [apps/web/app/api/veriff/webhook/handle-decision-event.ts:32-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L32-L141)
### Veriff Decision Status Mapping
Veriff webhook decision events report raw verification statuses that are translated directly into Prisma `IdentityVerificationStatus` enum values.
| Veriff Raw Status | Prisma `IdentityVerificationStatus` Mapping | Sources |
| :--- | :--- | :--- |
| `approved` | `approved` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
| `declined` | `declined` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
| `expired` | `expired` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
| `abandoned` | `abandoned` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
| `review` | `review` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
| `resubmission_requested` | `resubmissionRequested` | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30) |
Sources: [apps/web/app/api/veriff/webhook/handle-decision-event.ts:20-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L20-L30)
### Webhook Event Handling Call-Chain
When `handleDecisionEvent` receives a Veriff webhook payload, it executes a strict sequence of validation, fraud evaluation, and database synchronization steps:
1. `handleSessionDecision()` extracts verification details including `id`, `status`, `decisionTime`, `reason`, `attemptId`, and `riskLabels`.
2. `prisma.partner.findUnique()` searches for a partner matching `veriffSessionId: id`.
3. If `effectiveStatus === "approved"`, `checkCountryMismatch()` and duplicate risk label checks are evaluated; if either triggers, `effectiveStatus` is overridden to `"declined"`.
4. `parseVeriffMetadata()` and `mergeVeriffMetadata()` update the attempt count, decline reason, and clear active session URLs unless `resubmission_requested` is set.
5. `prisma.partner.update()` persists the new verification status, `identityVerifiedAt` timestamp, and merged metadata.
6. `sendEmailNotification()` dispatches the appropriate transactional email with an idempotency header derived from `attemptId`.
Sources: [apps/web/app/api/veriff/webhook/handle-decision-event.ts:32-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L32-L141)
> [!WARNING]
> If a partner's verified document country does not match their account country during webhook processing, `effectiveStatus` is forcefully overridden from `approved` to `declined`, even if Veriff's automated risk engine initially approved the session.
Sources: [apps/web/app/api/veriff/webhook/handle-decision-event.ts:95-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L95-L98)
### Country Change Verification Cron Route
The cron route at `POST /api/cron/partners/verify-country-change` periodically validates existing partner country settings against their verified Veriff session data:
```typescript
export const POST = withCron(async ({ rawBody }) => {
const { partnerId } = schema.parse(JSON.parse(rawBody));
const partner = await prisma.partner.findUnique({
where: { id: partnerId },
select: {
id: true,
name: true,
email: true,
country: true,
identityVerificationStatus: true,
identityVerifiedAt: true,
veriffSessionId: true,
veriffMetadata: true,
},
});
if (!partner || !partner.veriffSessionId || !partner.country) {
return logAndRespond("Partner, session ID, or country missing. Skipping.");
}
const { verification } = await veriffClient.fetchSessionDecision(
partner.veriffSessionId,
);
const documentCountry =
(verification.document?.country || verification.person?.nationality) ?? null;
if (partner.country.toLowerCase() === documentCountry?.toLowerCase()) {
if (!partner.identityVerifiedAt) {
await prisma.partner.update({
where: { id: partner.id },
data: { identityVerifiedAt: new Date() },
});
await sendEmail({
to: partner.email!,
subject: "Your identity has been verified",
react: PartnerIdentityVerified({
partner: { name: partner.name, email: partner.email! },
}),
});
}
} else {
const declineReason =
"Your account country no longer matches your verified identity document country. Please re-verify.";
const { attemptCount } = parseVeriffMetadata(partner.veriffMetadata);
await prisma.partner.update({
where: { id: partner.id },
data: {
identityVerificationStatus: null,
identityVerifiedAt: null,
veriffSessionId: null,
veriffMetadata: mergeVeriffMetadata(partner.veriffMetadata, {
sessionUrl: null,
sessionExpiresAt: null,
declineReason,
attemptCount,
}),
},
});
await sendEmail({
to: partner.email!,
subject: "Identity re-verification required",
react: PartnerIdentityVerificationFailed({
failureType: "countryChange",
failureReasonText: declineReason,
partner: { name: partner.name, email: partner.email! },
}),
});
}
return logAndRespond("Country verification check completed.");
});
```
Sources: [apps/web/app/ee/api/cron/partners/verify-country-change/route.ts:21-140](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/verify-country-change/route.ts#L21-L140)
> [!NOTE]
> When a country mismatch is detected by the cron route, the partner's verification status, verified timestamp, and active session ID are reset to `null`, while the cumulative `attemptCount` is preserved through metadata merging.
Sources: [apps/web/app/ee/api/cron/partners/verify-country-change/route.ts:98-119](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/partners/verify-country-change/route.ts#L98-L119)
## Duplicate Identity Fraud Detection
### Overview
When Veriff flags a decision event with risk labels indicating duplicate identities, the webhook handler overrides the verification status to declined and asynchronously dispatches `detectDuplicateIdentityFraud`. This routine evaluates program enrollments against configured fraud rules and executes automated financial remediation by holding pending and processed commissions across affected accounts.
Sources: [apps/web/app/api/veriff/webhook/handle-decision-event.ts:85-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L85-L94), [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L23)
### Fraud Detection Call-Chain Execution
The execution flow for identifying and acting on duplicate identity fraud proceeds through a series of filtering, grouping, and persistence steps:
`detectDuplicateIdentityFraud()` → filters `riskLabels` against `veriffRiskLabels` and extracts associated `sessionIds` → queries `prisma.programEnrollment.findMany()` for matching partners → filters enrollments via `isFraudRuleEnabled()` for `FraudRuleType.partnerDuplicateAccount` → groups enrollments by `programId` via `partnersByProgram` reducer → filters out programs with fewer than two partners → builds `CreateFraudEventInput` records for active enrollments → persists fraud events via `createFraudEvents()` → settles commission freezes via `holdPendingCommissions()` and `holdProcessedCommissions()`.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L144)
> [!TIP]
> `detectDuplicateIdentityFraud` automatically merges the current verification session ID into the extracted risk label session IDs and de-duplicates the resulting array before querying program enrollments.
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:30-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L30-L39)
### Fraud Detection Parameters and Criteria
| Parameter / Entity | Source Definition | Purpose / Condition | Sources |
| :--- | :--- | :--- | :--- |
| `veriffSessionId` | `VeriffDecisionEvent["verification"]["id"]` | Current session identifier injected into the target session pool. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L23) |
| `riskLabels` | `VeriffDecisionEvent["verification"]["riskLabels"]` | Array of risk labels returned by Veriff containing related session IDs. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:17-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L17-L23) |
| `FraudRuleType.partnerDuplicateAccount` | `@prisma/client` | Fraud rule type evaluated to determine if duplicate account checks are active for a program. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:1-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L1-L15) |
| `INACTIVE_ENROLLMENT_STATUSES` | `@/lib/zod/schemas/partners` | Status list used to skip inactive program enrollments during fraud event generation. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:1-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L1-L15) |
Sources: [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:1-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L1-L15)
### Design Trade-Offs in Fraud Evaluation
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| Asynchronous execution via `waitUntil` | Prevents webhook timeout by deferring heavy database queries and commission updates. | Errors during background processing must be caught via promise settling rather than returning HTTP failure codes. | [apps/web/app/api/veriff/webhook/handle-decision-event.ts:89-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L89-L94), [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:137-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L137-L144) |
| Program-scoped grouping | Isolates duplicate detection to specific referral programs where rules are enabled. | Requires querying and filtering enrollments across all associated sessions before grouping. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:83-102](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L83-L102) |
| Parallel commission holding via `Promise.allSettled` | Executes pending and processed commission holds concurrently without cascading failures. | Requires manual iteration over rejected settlement results to log errors. | [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:137-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L137-L144) |
Sources: [apps/web/app/api/veriff/webhook/handle-decision-event.ts:89-94](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/veriff/webhook/handle-decision-event.ts#L89-L94), [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:83-102](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L83-L102), [apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts:137-144](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/fraud/detect-duplicate-identity-fraud.ts#L137-L144)
## Partner and Admin UI Surfaces
### Overview
The partner portal exposes verification components across several interactive elements, including profile sections, promotional banners, and floating reminder cards. The `IdentityVerificationSection` component renders within the partner profile view, handling state transitions for verification status, attempt counts, and error messaging.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:21-28](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L21-L28), [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:60-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L60-L91)
When an action is initiated via `startIdentityVerificationAction`, the client executes the mutation and dynamically loads `@veriff/incontext-sdk` to instantiate a Veriff frame with the returned `sessionUrl`.
```typescript
const { executeAsync, isPending } = useAction(
startIdentityVerificationAction,
{
onError: ({ error }) => {
toast.error(
parseActionError(error, "Failed to start identity verification."),
);
},
onSuccess: async ({ data }) => {
const { createVeriffFrame, MESSAGES } = await import(
"@veriff/incontext-sdk"
);
createVeriffFrame({
url: data.sessionUrl,
onEvent: (msg) => {
if (msg === MESSAGES.FINISHED) {
toast.success(
"Verification submitted. We'll update your status shortly.",
);
mutate();
}
},
});
mutate();
},
},
);
```
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:30-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L30-L58)
> [!NOTE]
> Maximum verification attempts are tracked via `identityVerificationAttemptCount` against the `MAX_PARTNER_IDENTITY_VERIFICATION_ATTEMPTS` threshold. Reaching the limit disables the start button unless the account is approved or currently pending review.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:79-84](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L79-L84), [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:211-218](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L211-L218)
### Promotional Banners and Cards
Partners receive prominent prompts to verify their identity via `IdentityVerificationBanner` and `IdentityVerificationCard`. The banner displays a radial-gradient background with floating shield assets, linking directly to `/profile#identity-verification` or allowing dismissal to the card layout by updating the promo state to `card`.
Sources: [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:13-38](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L13-L38), [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:82-98](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L82-L98)
| Component Name | File Path | Visibility Condition | Action / Target | Sources |
| :--- | :--- | :--- | :--- | :--- |
| `IdentityVerificationBanner` | `apps/web/ui/partners/identity-verification/identity-verification-banner.tsx` | `partner` exists and banner status is active | Links to `/profile#identity-verification` or dismisses to card | [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:13-19](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L13-L19) |
| `IdentityVerificationCard` | `apps/web/ui/partners/identity-verification/identity-verification-card.tsx` | `partner` exists, status is `card`, and pathname is not `/profile` | Links to `/profile#identity-verification` | [apps/web/ui/partners/identity-verification/identity-verification-card.tsx:13-21](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-card.tsx#L13-L21) |
Sources: [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:13-19](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L13-L19), [apps/web/ui/partners/identity-verification/identity-verification-card.tsx:13-21](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-card.tsx#L13-L21)
### Administrative Review Surfaces
Administrators manage network partner verification via `NetworkIdentityVerification`, which exposes tools to generate Veriff session URLs or manually verify partners with a US LLC.
```typescript
export function NetworkIdentityVerification({
partner,
}: {
partner: Pick;
}) {
const [sessionUrl, setSessionUrl] = useState(null);
const [isGeneratingSession, setIsGeneratingSession] = useState(false);
const [isVerifyingIdentity, setIsVerifyingIdentity] = useState(false);
const partnerIdRef = useRef(partner.id);
...
```
Sources: [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:10-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L10-L19)
> [!WARNING]
> Manual verification via `handleVerifyIdentity` posts to `/api/admin/partners/${requestPartnerId}/verify-identity` and is restricted if `identityVerifiedAt` is already set.
Sources: [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:72-83](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L72-L83), [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:159-163](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L159-L163)
### UI Component Design Trade-Offs
| Design Choice | Benefit | Cost | Sources |
| :--- | :--- | :--- | :--- |
| Dynamic import of Veriff SDK (`@veriff/incontext-sdk`) | Reduces initial bundle size by loading the Veriff client-side framework only when verification starts. | Introduces a network load step upon click before the iframe can be initialized. | [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L39-L43) |
| Component-level ref tracking (`partnerIdRef`) in admin view | Prevents stale async state updates if a different partner row is selected while a request is pending. | Requires redundant checks against `partnerIdRef.current` across fetch catch and finally blocks. | [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:18-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L18-L25), [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:49-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L49-L68) |
| Conditional banner/card state persistence | Allows users to minimize intrusive banners into compact cards without losing prompt visibility. | Requires maintaining local promo display state across client navigation. | [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:15-16](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L15-L16) |
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/identity-verification-section.tsx:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/identity-verification-section.tsx#L39-L43), [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:18-25](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L18-L25), [apps/web/app/ee/admin.dub.co/dashboard/partners/network/identity-verification.tsx:49-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/network/identity-verification.tsx#L49-L68), [apps/web/ui/partners/identity-verification/identity-verification-banner.tsx:15-16](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/identity-verification/identity-verification-banner.tsx#L15-L16)
## Related
- [[Fraud Detection and Hold Rules]]
- [[Payout Processing]]
---
## Technical docs: POST Add program to marketplace
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/addprogramtomarketplace
## Request Body
Program slug
## Responses
## Try It
---
## Technical docs: Audit Logging
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/authentication-and-security/audit-logging
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/api/audit-logs/get-audit-logs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts)
- [apps/web/app/ee/api/audit-logs/export/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/audit-logs.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/audit-logs.tsx)
- [apps/web/app/ee/api/cron/export/events/partner/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/partner/route.ts)
- [apps/web/app/ee/api/cron/export/events/workspace/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/export/events/workspace/route.ts)
- [apps/web/lib/api/activity-log/track-reward-overrides.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-overrides.ts)
- [apps/web/lib/api/audit-logs/record-audit-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts)
- [apps/web/lib/api/audit-logs/schemas.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts)
- [apps/web/app/ee/api/partner-profile/programs/programId/activity-logs/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/activity-logs/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/page-client.tsx)
- [apps/web/app/api/activity-logs/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/activity-logs/route.ts)
- [apps/web/lib/api-logs/capture-webhook-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/capture-webhook-log.ts)
- [apps/web/app/api/logs/logId/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/%5BlogId%5D/route.ts)
- [apps/web/lib/swr/use-activity-logs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-activity-logs.ts)
- [apps/web/app/api/logs/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/route.ts)
- [apps/web/prisma/schema/activity.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma)
- [apps/web/app/ee/api/shopify/integration/webhook/shop-redact.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/shopify/integration/webhook/shop-redact.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/webhooks/webhookId/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/webhooks/%5BwebhookId%5D/page-client.tsx)
- [apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx)
- [apps/web/lib/api/activity-log/track-activity-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/logs/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/logs/page.tsx)
- [apps/web/app/ee/api/intercom/webhook/uninstall/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/uninstall/route.ts)
- [apps/web/lib/api/commissions/track-commission-update-activity-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts)
- [apps/web/lib/api/activity-log/track-reward-activity-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts)
- [apps/web/ui/activity-logs/partner-enrollment-activity-section.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-activity-section.tsx)
- [apps/web/lib/swr/use-partner-activity-logs.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-activity-logs.ts)
- [apps/web/lib/zod/schemas/activity-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/activity-log.ts)
- [apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx)
- [apps/web/lib/zod/schemas/workspaces.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/workspaces.ts)
- [apps/web/lib/api-logs/record-api-log.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/record-api-log.ts)
## Overview
Dub provides a comprehensive audit logging and activity tracking subsystem designed to record, ingest, persist, and export operational changes across workspaces, partner programs, and resources. By combining Tinybird analytics pipes for high-throughput event streaming with relational Prisma models for structured resource state transitions, the system captures administrative actions, partner enrollments, reward overrides, commission updates, and inbound webhook or API requests. This architecture ensures strict compliance, transparent audit trails, and robust diagnostic visibility for enterprise workspaces. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:1-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L1-L53), [apps/web/lib/api/audit-logs/record-audit-log.ts:1-80](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L1-L80), [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20)
## Tinybird Audit Log Ingestion
### Overview
Workspace audit events are ingested, enriched, and dispatched to Tinybird through a dedicated pipeline that captures administrative and programmatic actions across Dub workspaces. When an event is recorded via `recordAuditLog()`, the function inspects incoming HTTP requests to extract client IP addresses and user agents, transforms the payload into the required schema format with prefixed identifiers, and sends the batch or single event to the Tinybird ingestion endpoint (`dub_audit_logs`). Conversely, retrieval operations query Tinybird pipes using `getAuditLogs()` with date-range filters and prefixed workspace identifiers. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:1-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L1-L52), [apps/web/lib/api/audit-logs/record-audit-log.ts:1-80](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L1-L80)
### Event Ingestion and Transformation Call Chain
The recording and dispatch of audit logs follow a strict execution path from input validation to upstream delivery:
1. `recordAuditLog(data)` — Accepts single `AuditLogInput` objects or arrays, retrieves HTTP headers via `headers()`, and resolves client IP addresses using either Vercel's `getIPAddress(dataReq)` or fallback `getIP()`. Sources: [apps/web/lib/api/audit-logs/record-audit-log.ts:47-53](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L47-L53)
2. `transformAuditLogTB(data, context)` — Parses input against `recordAuditLogInputSchema`, extracts user-agent headers, generates unique identifiers via `createId({ prefix: "audit_" })`, formats ISO timestamps, prefixes workspace identifiers with `prefixWorkspaceId()`, and serializes targets and metadata objects into JSON strings. Sources: [apps/web/lib/api/audit-logs/record-audit-log.ts:13-45](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L13-L45)
3. `recordAuditLogTB(auditLogs)` — Built using `tb.buildIngestEndpoint()`, this function dispatches the transformed payloads to the `dub_audit_logs` Tinybird datasource with `wait: true`. If transmission fails, errors are logged to console and reported via the internal error handler `log()`. Sources: [apps/web/lib/api/audit-logs/record-audit-log.ts:58-79](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L58-L79)
Sources: [apps/web/lib/api/audit-logs/record-audit-log.ts:13-79](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L13-L79)
> [!WARNING]
> If `recordAuditLogTB` throws an exception during Tinybird ingestion, the failure is caught locally, logged to the console alongside the serialized audit payload, and dispatched to the internal notification system via `log()`. This prevents audit logging failures from crashing parent API mutations while ensuring administrative visibility into ingestion outages. Sources: [apps/web/lib/api/audit-logs/record-audit-log.ts:58-72](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/record-audit-log.ts#L58-L72)
### Audit Log Schema and Target Types
The ingestion schema enforces strict typing for stored audit attributes. Every record contains standard tracking properties mapped to Tinybird data columns. Sources: [apps/web/lib/api/audit-logs/schemas.ts:15-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L15-L30)
| Field Name | Zod Type | Nullable | Description |
| :--- | :--- | :--- | :--- |
| `id` | `z.string()` | No | Unique audit log identifier prefixed with `audit_`. Sources: [apps/web/lib/api/audit-logs/schemas.ts:17-17](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L17) |
| `timestamp` | `z.string()` | No | ISO 8601 timestamp generated at record creation. Sources: [apps/web/lib/api/audit-logs/schemas.ts:18-18](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L18) |
| `workspace_id` | `z.string()` | No | Prefixed workspace identifier associated with the event. Sources: [apps/web/lib/api/audit-logs/schemas.ts:19-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L19) |
| `program_id` | `z.string()` | No | Program identifier associated with the workspace action. Sources: [apps/web/lib/api/audit-logs/schemas.ts:20-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L20) |
| `action` | `z.string()` | No | Categorized action string validated against supported actions. Sources: [apps/web/lib/api/audit-logs/schemas.ts:21-21](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L21) |
| `actor_id` | `z.string()` | No | Identifier of the user or system entity performing the action. Sources: [apps/web/lib/api/audit-logs/schemas.ts:22-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L22) |
| `actor_type` | `z.string()` | No | Classification of the actor, defaulting to `user`. Sources: [apps/web/lib/api/audit-logs/schemas.ts:23-23](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L23) |
| `actor_name` | `z.string()` | No | Display name of the actor. Sources: [apps/web/lib/api/audit-logs/schemas.ts:24-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L24) |
| `description` | `z.string()` | No | Human-readable narrative description of the event. Sources: [apps/web/lib/api/audit-logs/schemas.ts:25-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L25) |
| `targets` | `z.string()` | Yes | JSON-serialized array of targeted resources and entity metadata. Sources: [apps/web/lib/api/audit-logs/schemas.ts:26-26](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L26) |
| `ip_address` | `z.string()` | Yes | Client IP address captured from request headers or Vercel runtime. Sources: [apps/web/lib/api/audit-logs/schemas.ts:27-27](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L27) |
| `user_agent` | `z.string()` | Yes | Client user-agent string extracted from request headers. Sources: [apps/web/lib/api/audit-logs/schemas.ts:28-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L28) |
| `metadata` | `z.string()` | Yes | JSON-serialized custom metadata dictionary. Sources: [apps/web/lib/api/audit-logs/schemas.ts:29-29](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L29) |
Sources: [apps/web/lib/api/audit-logs/schemas.ts:16-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/schemas.ts#L16-L30)
## Audit Log Export and Security
### Overview
Enterprise audit log querying and export relies on Tinybird pipes, Zod schema validation, and specialized conversion utilities to package workspace activity for compliance. The querying layer validates incoming filters through `auditLogFilterSchemaTB`, which prefixes workspace identifiers and extracts date bounds before executing queries against Tinybird pipes via `getAuditLogs`. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:6-52](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L6-L52)
### Audit Log Query and Export Pipeline
The export route `POST /api/audit-logs/export` orchestrates compliance downloads by parsing request bodies for `start` and `end` parameters, enforcing enterprise plan capabilities, and streaming formatted CSV output. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:10-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L10-L60)
The execution proceeds through a strict call chain:
1. `parseRequestBody(req)` extracts JSON payloads containing start and end dates validated by `auditLogExportQuerySchema`. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:18-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L18-L20)
2. `getPlanCapabilities(workspace.plan)` evaluates whether `canExportAuditLogs` is enabled for the workspace plan, throwing a `DubApiError` with code `forbidden` if unauthorized. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:29-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L29-L36)
3. `getDefaultProgramIdOrThrow(workspace)` retrieves the active program identifier required for log retrieval. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:38-38](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L38)
4. `getAuditLogs(...)` invokes Tinybird pipe `get_audit_logs` with UTC-formatted date strings. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:38-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L38-L51), [apps/web/app/ee/api/audit-logs/export/route.ts:40-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L40-L45)
5. `convertToCSV(auditLogs)` serializes the returned event records into CSV format, returned with headers `Content-Type: application/csv` and `Content-Disposition: attachment;`. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:47-54](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L47-L54)
Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:16-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L16-L55)
> [!WARNING]
> Requests to `/api/audit-logs/export` without enterprise plan capabilities trigger a `forbidden` API error. The client-side UI component `AuditLogs` disables date pickers and export triggers when `canExportAuditLogs` evaluates to false, rendering an upgrade CTA banner instead. Sources: [apps/web/app/ee/api/audit-logs/export/route.ts:29-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/audit-logs/export/route.ts#L29-L36), [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/audit-logs.tsx:81-129](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/audit-logs.tsx#L81-L129)
### Tinybird Response and Filter Schemas
The audit log retrieval interface validates both filter inputs and response row shapes using Zod v4. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:3-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L3-L25)
| Schema Name | Target Field / Property | Zod Definition / Type | Purpose |
| :--- | :--- | :--- | :--- |
| `auditLogFilterSchemaTB` | `workspaceId` | `z.string().transform(prefixWorkspaceId)` | Validates and prefixes workspace ID. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:7-7](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L7) |
| `auditLogFilterSchemaTB` | `programId` | `z.string()` | Identifies the program context. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:8-8](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L8) |
| `auditLogFilterSchemaTB` | `start` / `end` | `z.string()` | Query time boundaries. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:9-10](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L9-L10) |
| `auditLogResponseSchemaTB` | `id` / `timestamp` / `action` | `z.string()` | Core event metadata identifiers. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:14-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L14-L16) |
| `auditLogResponseSchemaTB` | `actor_id` / `actor_type` / `actor_name` | `z.string()` | Actor identity attributes. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:17-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L17-L19) |
| `auditLogResponseSchemaTB` | `description` / `ip_address` / `user_agent` | `z.string()` | Narrative and client connection details. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:20-22](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L20-L22) |
| `auditLogResponseSchemaTB` | `targets` / `metadata` | `z.string()` | Serialized JSON target resources and custom metadata. Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:23-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L23-L24) |
Sources: [apps/web/lib/api/audit-logs/get-audit-logs.ts:6-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/audit-logs/get-audit-logs.ts#L6-L25)
### Client UI Settings and Export Execution
The client component `AuditLogs` manages state for date ranges and export loading spinners inside the workspace security settings view (`WorkspaceSecurityClient`). Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/audit-logs.tsx:12-66](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/audit-logs.tsx#L12-L66), [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/page-client.tsx:7-15](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/page-client.tsx#L7-L15)
```typescript
export async function exportAuditLogs() {
const response = await fetch(
`/api/audit-logs/export?workspaceId=${workspaceId}`,
{
method: "POST",
body: JSON.stringify({ start, end }),
headers: { "Content-Type": "application/json" },
},
);
if (!response.ok) {
const { error } = await response.json();
throw new Error(error.message);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `Dub Audit Logs Export - ${new Date().toISOString()}.csv`;
a.click();
}
```
Sources: [apps/web/app/app.dub.co/dashboard/slug/ee/settings/security/audit-logs.tsx:22-66](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/security/audit-logs.tsx#L22-L66)
## Prisma Activity Log Persistence
### Overview
The relational activity log module persists workspace state transitions, partner modifications, and resource actions using a Prisma schema backed by tracking helper utilities. It filters out unchangeable records and supports batch creation both standalone and within transactional client boundaries. Sources: [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20), [apps/web/lib/api/activity-log/track-activity-log.ts:1-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L1-L93)
### Relational Schema and Indexes
The `ActivityLog` Prisma model defines fields for tracking identifiers, resource scopes, change sets, and user relations. Sources: [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20)
| Column Name | Prisma Type | Modifiers / Attributes | Description |
| :--- | :--- | :--- | :--- |
| `id` | `String` | `@id @default(cuid())` | Unique identifier for the activity log entry. Sources: [apps/web/prisma/schema/activity.prisma:2-2](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L2) |
| `workspaceId` | `String` | None | Workspace context identifier. Sources: [apps/web/prisma/schema/activity.prisma:3-3](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L3) |
| `programId` | `String` | None | Program context identifier. Sources: [apps/web/prisma/schema/activity.prisma:4-4](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L4) |
| `parentResourceType` | `String` | `@nullable` | Optional parent resource classification. Sources: [apps/web/prisma/schema/activity.prisma:5-5](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L5) |
| `parentResourceId` | `String` | `@nullable` | Optional parent resource identifier. Sources: [apps/web/prisma/schema/activity.prisma:6-6](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L6) |
| `resourceType` | `String` | None | Target resource classification. Sources: [apps/web/prisma/schema/activity.prisma:7-7](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L7) |
| `resourceId` | `String` | None | Target resource identifier. Sources: [apps/web/prisma/schema/activity.prisma:8-8](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L8) |
| `userId` | `String` | `@nullable` | Optional actor user identifier. Sources: [apps/web/prisma/schema/activity.prisma:9-9](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L9) |
| `action` | `String` | None | Performed action identifier string. Sources: [apps/web/prisma/schema/activity.prisma:10-10](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L10) |
| `description` | `String` | `@nullable @db.Text` | Optional human-readable narrative text. Sources: [apps/web/prisma/schema/activity.prisma:11-11](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L11) |
| `batchId` | `String` | `@nullable` | Optional batch grouping identifier. Sources: [apps/web/prisma/schema/activity.prisma:12-12](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L12) |
| `changeSet` | `Json` | `@nullable` | Structured diff payload JSON. Sources: [apps/web/prisma/schema/activity.prisma:13-13](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L13) |
| `createdAt` | `DateTime` | `@default(now())` | Timestamp when the entry was recorded. Sources: [apps/web/prisma/schema/activity.prisma:14-14](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L14) |
Sources: [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20)
> [!NOTE]
> The table configuration includes composite indexing on `[resourceType, resourceId]` alongside a standalone index on `userId` to optimize relational lookup performance. Sources: [apps/web/prisma/schema/activity.prisma:18-19](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L18-L19)
### Tracking Helper Execution Flow
The activity tracking subsystem processes single objects or arrays, filters out logs that lack required change sets unless whitelisted, and executes batch persistence. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:34-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L34-L65)
```typescript
const ACTIONS_WITHOUT_CHANGE_SET: ActivityLogAction[] = [
"submittedLead.created",
"reward.created",
"reward.deleted",
];
```
Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:11-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L11-L15)
Call-chain execution for standalone activity logging: `trackActivityLog()` → array coercion (`Array.isArray`) → `inputs.filter()` checking `ACTIONS_WITHOUT_CHANGE_SET` or populated `changeSet` → length check abort (`if (inputs.length === 0) return`) → `prisma.activityLog.createMany()` mapping json payloads → console logging or `logger.error` catch block with `logger.flush()`. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:34-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L34-L65)
> [!WARNING]
> If an input action is not present in `ACTIONS_WITHOUT_CHANGE_SET`, an empty or missing `changeSet` object will cause the helper to filter out and discard the log entry entirely before reaching the database. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:39-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L39-L43)
### Transactional Logging and Zod Resource Validation
When operations must run atomically within caller transactions, `trackActivityLogsTx()` accepts an active `Prisma.TransactionClient` instance (`tx`) and performs identical validation before executing `tx.activityLog.createMany()`. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:68-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L68-L93)
Resource types and actions are strictly constrained by Zod v4 schemas. Resource types include `partner`, `commission`, `clickReward`, `saleReward`, `leadReward`, `referralReward`, `customReward`, and `submittedLead`. Sources: [apps/web/lib/zod/schemas/activity-log.ts:4-13](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/activity-log.ts#L4-L13)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| **Whitelisted Actions Without ChangeSet** | Allows creation events (like rewards and leads) to log without prior diff states. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:11-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L11-L15) | Requires maintaining explicit exemption arrays when new lifecycle creation actions are introduced. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:11-15](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L11-L15) |
| **Separate Transactional Helper (`trackActivityLogsTx`)** | Prevents dangling activity records when parent business logic transactions roll back. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:68-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L68-L93) | Callers must explicitly thread the transaction client `tx` through repository service layers. Sources: [apps/web/lib/api/activity-log/track-activity-log.ts:68-74](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L68-L74) |
| **JSON ChangeSet Column with FieldDiff Schema** | Stores arbitrary structural updates (`old` and `new` unknown values) flexibly. Sources: [apps/web/prisma/schema/activity.prisma:13-13](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L13), [apps/web/lib/zod/schemas/activity-log.ts:57-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/activity-log.ts#L57-L60) | Loses relational column-level indexing on inner diff properties within queries. Sources: [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20) |
Sources: [apps/web/prisma/schema/activity.prisma:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/activity.prisma#L1-L20), [apps/web/lib/api/activity-log/track-activity-log.ts:11-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-activity-log.ts#L11-L93), [apps/web/lib/zod/schemas/activity-log.ts:57-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/activity-log.ts#L57-L60)
## Partner and Commission Change Tracking
### Overview
Partner and commission change tracking manages state transitions across rewards, partner discount overrides, link-level customizations, and commission adjustments. The logging pipeline generates structured snapshots and computes field-level differences using dedicated utility functions before recording entries to the database. Sources: [apps/web/lib/api/activity-log/track-reward-overrides.ts:1-57](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-overrides.ts#L1-L57), [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:1-30](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L1-L30), [apps/web/lib/api/activity-log/track-reward-activity-log.ts:1-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts#L1-L32)
### Commission Update Tracking and Snapshot Generation
Commission tracking operates on arrays of old and new commission records containing identifier, amount, earnings, and status fields. The `trackCommissionActivityLog` function coordinates this process through explicit step sequencing. Sources: [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:31-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L31-L75)
Call-chain execution for commission updates: `trackCommissionActivityLog()` → id extraction & sorting (`oldById`, `newById`, `commissionIds`) → `toCommissionActivitySnapshot()` → `getResourceDiff()` evaluating `COMMISSION_ACTIVITY_FIELDS` (`amount`, `earnings`, `status`) → conditional activity log push with action `commission.updated` → `trackActivityLog()`. Sources: [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:19-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L19-L75)
```typescript
export async function trackCommissionActivityLog({
old: oldCommissions,
new: newCommissions,
...baseInput
}: TrackActivityLogParams) {
const activityLogs: TrackActivityLogInput[] = [];
const oldById = new Map((oldCommissions ?? []).map((c) => [c.id, c]));
const newById = new Map((newCommissions ?? []).map((c) => [c.id, c]));
const commissionIds = [
...new Set([...oldById.keys(), ...newById.keys()]),
].sort();
for (const id of commissionIds) {
const oldCommission = oldById.get(id);
const newCommission = newById.get(id);
if (oldCommission && newCommission) {
const oldSnapshot = toCommissionActivitySnapshot(oldCommission);
const newSnapshot = toCommissionActivitySnapshot(newCommission);
const diff = getResourceDiff(oldSnapshot, newSnapshot, {
fields: COMMISSION_ACTIVITY_FIELDS,
});
if (diff) {
activityLogs.push({
...baseInput,
resourceId: newCommission.id,
resourceType: "commission",
action: "commission.updated",
changeSet: {
commission: {
old: oldSnapshot,
new: newSnapshot,
},
},
});
}
}
}
return await trackActivityLog(activityLogs);
}
```
Sources: [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:31-75](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L31-L75)
Bulk status changes leverage wrapper utilities. `trackCommissionStatusUpdate` applies a uniform `CommissionStatus` across a filtered commission set, whereas `trackCommissionStatusUpdatesByProgram` groups commissions by program ID and resolves the corresponding workspace from associated payout configurations before delegation. Sources: [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:77-142](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L77-L142)
> [!NOTE]
> `trackCommissionStatusUpdatesByProgram` will log an error to the console and skip processing for any program whose workspace ID cannot be resolved from the provided payout records. Sources: [apps/web/lib/api/commissions/track-commission-update-activity-log.ts:127-134](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/track-commission-update-activity-log.ts#L127-L134)
### Reward Lifecycle and Modifier Change Tracking
Reward activity tracking handles creation, updates, deletions, and conditional modifier changes. The helper `trackRewardActivityLog` determines resource types using `REWARD_EVENT_TO_RESOURCE_TYPE` mapped from the reward event property. Sources: [apps/web/lib/zod/schemas/activity-log.ts:71-77](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/activity-log.ts#L71-L77), [apps/web/lib/api/activity-log/track-reward-activity-log.ts:9-143](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts#L9-L143)
Modifier changes within rewards are evaluated by `buildModifierChangeSetEntries`, which maps old and new modifier arrays by identifier and inspects equality via `modifierEquals` (performing stringified comparison). Changes produce distinct actions: `reward.conditionAdded`, `reward.conditionUpdated`, or `reward.conditionRemoved`. Sources: [apps/web/lib/api/activity-log/track-reward-activity-log.ts:34-125](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts#L34-L125)
| Reward Action | Trigger Condition | ChangeSet Structure |
| :--- | :--- | :--- |
| `reward.created` | `old` is `null` and `new` is present | `{ reward: { old: null, new: newSnapshot } }` |
| `reward.updated` | Both `old` and `new` are present, and `getResourceDiff` finds changes | `{ reward: { old: oldSnapshot, new: newSnapshot } }` |
| `reward.deleted` | `old` is present and `new` is `null` | `{ reward: { old: oldSnapshot, new: null } }` |
| `reward.conditionAdded` | Modifier ID exists in new modifiers but not old | `{ reward: { old: null, new: { ...newReward, modifiers: [newMod] } } }` |
| `reward.conditionUpdated` | Modifier ID exists in both but `modifierEquals` returns false | `{ reward: { old: { ...oldReward, modifiers: [oldMod] }, new: { ...newReward, modifiers: [newMod] } } }` |
| `reward.conditionRemoved` | Modifier ID exists in old modifiers but is missing in new | `{ reward: { old: { ...oldReward, modifiers: [oldMod] }, new: null } }` |
Sources: [apps/web/lib/api/activity-log/track-reward-activity-log.ts:145-225](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts#L145-L225)
> [!WARNING]
> If a description is provided alongside multiple reward activity logs (such as an update accompanied by modifier adjustments), the description is assigned exclusively to the primary lifecycle log index (`reward.created`, `reward.updated`, or `reward.deleted`), defaulting to index `0` if no primary action is found. Sources: [apps/web/lib/api/activity-log/track-reward-activity-log.ts:227-239](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-activity-log.ts#L227-L239)
### Partner and Link Reward Overrides
Partner reward and discount overrides are tracked via `trackPartnerRewardOverrideLog` (executing within an active Prisma transaction) and `trackLinkRewardOverrideLog` (executing independently via `prisma`). Both functions inspect reward fields (`clickReward`, `leadReward`, `saleReward`) and discount changes. Sources: [apps/web/lib/api/activity-log/track-reward-overrides.ts:30-193](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-overrides.ts#L30-L193)
If reward identifiers or discount identifiers change, the respective records are queried in batch using `Promise.all`, serialized into activity snapshots, and committed as `partner.rewardChanged` or `partner.discountChanged` activity log entries. Sources: [apps/web/lib/api/activity-log/track-reward-overrides.ts:44-178](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/activity-log/track-reward-overrides.ts#L44-L178)
## Activity Feeds and Audit Presentation
### Overview
Frontend integration of audit trails and partner activity feeds relies on specialized SWR hooks, slide-over sheet drawers, and dedicated action renderers. Activity log data is retrieved from workspace and partner-profile endpoints using query parameter filters for resource type, resource ID, parent resource ID, and specific actions. Sources: [apps/web/app/api/activity-logs/route.ts:12-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/activity-logs/route.ts#L12-L34), [apps/web/lib/swr/use-activity-logs.ts:6-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-activity-logs.ts#L6-L34), [apps/web/lib/swr/use-partner-activity-logs.ts:6-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-activity-logs.ts#L6-L31)
### SWR Data Fetching Hooks
Data retrieval for activity logs is implemented via modular hooks that handle conditional execution and parameter serialization. The workspace-level `useActivityLogs` hook verifies workspace resolution and query requirements before invoking the fetcher with serialized URL search parameters, preserving previous data across updates. Sources: [apps/web/lib/swr/use-activity-logs.ts:6-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-activity-logs.ts#L6-L34)
| Hook Name | Target Endpoint | Enabling Conditions | Returned Properties |
| :--- | :--- | :--- | :--- |
| `useActivityLogs` | `/api/activity-logs?${searchParams}` | `enabled && workspaceId && query?.resourceType && (query?.parentResourceId || query?.resourceId)` | `activityLogs`, `error`, `loading`, `mutate` |
| `usePartnerActivityLogs` | `/api/partner-profile/programs/${programSlug}/activity-logs?${searchParams}` | `enabled && programSlug && query?.resourceType && query?.resourceId` | `activityLogs`, `error`, `loading`, `mutate` |
| `usePartnerEnrollmentHistorySheet` | Route query params via `useRouterStuff` | `partner?.id` exists | `hasActivityLogs`, `partnerEnrollmentHistorySheet`, `setIsOpen` |
Sources: [apps/web/lib/swr/use-activity-logs.ts:6-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-activity-logs.ts#L6-L42), [apps/web/lib/swr/use-partner-activity-logs.ts:6-39](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/swr/use-partner-activity-logs.ts#L6-L39), [apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx:52-93](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx#L52-L93)
### Sheet Drawers and Activity Sections
Partner history and audit events can be inspected via slide-over sheets powered by `PartnerEnrollmentHistorySheet`. The sheet component manages visibility state synchronized with URL search parameters via `useRouterStuff`, adding or deleting the `history` parameter. Sources: [apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx:41-93](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx#L41-L93)
The UI call sequence proceeds as follows:
`usePartnerEnrollmentHistorySheet()` evaluates the `history` search parameter → `setIsOpen()` updates URL parameters via `queryParams({ set: { history: "true" } })` → `PartnerEnrollmentHistorySheet` renders the sheet container → `PartnerEnrollmentActivitySection` invokes `useActivityLogs` with resource type `partner` → `ActivityFeed` displays the returned log entries. Sources: [apps/web/ui/activity-logs/partner-enrollment-activity-section.tsx:9-45](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-activity-section.tsx#L9-L45), [apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx:41-93](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx#L41-L93)
Sources: [apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx:41-93](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/partner-enrollment-history-sheet.tsx#L41-L93)
> [!NOTE]
> The partner profile activity log endpoint at `apps/web/app/(ee)/api/partner-profile/programs/[programId]/activity-logs/route.ts` explicitly overrides workspace user data by mapping retrieved activity logs to set `user: null`, preventing workspace operators' user profiles from being exposed to partners. Sources: [apps/web/app/ee/api/partner-profile/programs/programId/activity-logs/route.ts:63-68](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/programs/%5BprogramId%5D/activity-logs/route.ts#L63-L68)
### Action Renderers
Specific activity actions are interpreted by UI action renderers. For instance, `PartnerGroupChangedRenderer` handles `partner.groupChanged` actions by retrieving available workspace groups via `useGroups`, extracting the target group change set, and rendering dynamic labels and pills depending on whether an old group exists and whether a user or an automated group move triggered the event. Sources: [apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx:18-48](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx#L18-L48)
## API and Webhook Request Logging
### Overview
Incoming webhook deliveries and API requests are captured and persisted for diagnostic auditability using Tinybird pipes and ingestion endpoints. Request metadata, duration, response bodies, and routing patterns are normalized and recorded to the `dub_api_logs` datasource with robust retry logic. Sources: [apps/web/lib/api-logs/capture-webhook-log.ts:16-51](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/capture-webhook-log.ts#L16-L51), [apps/web/lib/api-logs/record-api-log.ts:30-93](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/record-api-log.ts#L30-L93)
### API Log Recording and Ingestion Pipeline
The recording execution flow proceeds as follows: `captureWebhookLog()` or external API handlers call `parseResponseBody()` to clone and extract JSON response payloads → `recordApiLog()` constructs an `ApiLogInput` object with a generated `req_` ID, ISO timestamp, workspace ID, path sanitization (`/api/` prefix replacement), and JSON-serialized request/response bodies → `recordApiLogTB()` invokes the Tinybird ingest endpoint via `tb.buildIngestEndpoint` with `wait: true` and `dub_api_logs` datasource → on network or ingestion failure, the loop sleeps with exponential backoff (`100 * Math.pow(2, attempt)` for up to 3 retries) before logging errors to Axiom or console. Sources: [apps/web/lib/api-logs/capture-webhook-log.ts:4-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/capture-webhook-log.ts#L4-L50), [apps/web/lib/api-logs/record-api-log.ts:8-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/record-api-log.ts#L8-L92)
```typescript
export const recordApiLog = async ({
workspaceId,
method,
path,
routePattern,
statusCode,
duration,
userAgent,
requestBody,
queryParams,
responseBody,
tokenId,
userId,
requestType,
}: RecordApiLogParams) => {
const apiLog: ApiLogInput = {
id: createId({ prefix: "req_" }),
timestamp: new Date().toISOString(),
workspace_id: workspaceId,
method,
path: path.replace("/api/", "/"),
route_pattern: routePattern,
status_code: statusCode,
duration,
user_agent: userAgent ?? "",
request_body: JSON.stringify(requestBody),
query_params: queryParams ? JSON.stringify(queryParams) : "",
response_body: JSON.stringify(responseBody),
token_id: tokenId ?? "",
user_id: userId ?? "",
request_type: requestType,
};
return await recordApiLogTB(apiLog);
};
```
Sources: [apps/web/lib/api-logs/record-api-log.ts:36-67](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/record-api-log.ts#L36-L67)
> [!WARNING]
> If all retry attempts fail when recording an API log, the failure is caught, checked against `process.env.CI`, and logged to the internal error monitoring service via `log()`, ensuring that logging failures do not crash the primary request lifecycle. Sources: [apps/web/lib/api-logs/record-api-log.ts:69-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api-logs/record-api-log.ts#L69-L92)
### API Log Retrieval and Plan Retention Enforcement
Workspace operators query recorded logs via `/api/logs` and `/api/logs/[logId]` endpoints protected by `withWorkspace` middleware requiring `workspaces.read` permissions. Retrieved logs are validated against workspace plan retention rules and enriched before returning JSON responses. Sources: [apps/web/app/api/logs/route.ts:8-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/route.ts#L8-L40), [apps/web/app/api/logs/logId/route.ts:9-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/%5BlogId%5D/route.ts#L9-L45)
| Endpoint Route | Required Permission | Query Validation Schema | Retention & Enrichment Behavior |
| :--- | :--- | :--- | :--- |
| `GET /api/logs` | `workspaces.read` | `getApiLogsQuerySchema` via `searchParams` | Applies `getApiLogsDateRange` based on workspace plan, fetches filtered logs, and enriches them. Sources: [apps/web/app/api/logs/route.ts:8-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/route.ts#L8-L40) |
| `GET /api/logs/:logId` | `workspaces.read` | Route parameter `logId` | Fetches single log by ID, throws `not_found` if missing or older than plan retention date, and enriches log data. Sources: [apps/web/app/api/logs/logId/route.ts:9-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/%5BlogId%5D/route.ts#L9-L45) |
Sources: [apps/web/app/api/logs/route.ts:8-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/route.ts#L8-L40), [apps/web/app/api/logs/logId/route.ts:9-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/logs/%5BlogId%5D/route.ts#L9-L45)
## Related
- [[Authentication and Sessions]]
- [[Enterprise SSO and SCIM]]
---
## Technical docs: PATCH Reorder marketplace programs
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/reorderprograms
## Request Body
Ranking updates array
## Responses
## Try It
---
## Technical docs: Workflow Automation
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/workflow-automation
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/workflows/create-partner-commission/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts)
- [apps/web/app/ee/api/cron/commissions/referrals/create/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts)
- [apps/web/lib/partner-referrals/create-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts)
- [apps/web/scripts/programs/backfill-reuse-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/backfill-reuse-commission.ts)
- [apps/web/app/ee/api/workflows/partner-approved/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/partner-approved/route.ts)
- [apps/web/app/ee/api/cron/bounties/upsert-draft-submissions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts)
- [apps/web/lib/api/customers/reattribute-customer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts)
- [apps/web/prisma/schema/workflow.prisma](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workflow.prisma)
- [apps/web/lib/api/workflows/execute-workflows.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/execute-workflows.ts)
- [apps/web/app/ee/api/workflows/reattribute-customer/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts)
- [apps/web/lib/jobs/send-workflows.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts)
- [apps/web/lib/openapi/commissions/create-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/commissions/create-commission.ts)
- [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts)
- [apps/web/scripts/programs/5-import-customer-sales.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/programs/5-import-customer-sales.ts)
- [apps/web/lib/api/commissions/create-manual-commissions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts)
- [apps/web/lib/partner-referrals/create-network-referral-commission.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-network-referral-commission.ts)
- [apps/web/lib/api/workflows/types.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/types.ts)
- [apps/web/scripts/customers/upheal/sync-stripe-invoices.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/upheal/sync-stripe-invoices.ts)
- [apps/web/lib/partners/queue-partner-commission-creation.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/queue-partner-commission-creation.ts)
- [apps/web/lib/api/conversions/track-lead.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts)
- [apps/web/app/ee/api/workflows/detach-discount/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts)
- [apps/web/lib/jobs/registry.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/registry.ts)
- [apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/customers/beehiiv/fix-case-a-complex.ts)
- [apps/web/lib/zod/schemas/workflows.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/workflows.ts)
- [apps/web/lib/api/workflows/attribute-definitions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/attribute-definitions.ts)
- [apps/web/lib/postback/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/constants.ts)
- [apps/web/lib/zod/schemas/rewards.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts)
- [apps/web/lib/api/workflows/attribute-validators.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/attribute-validators.ts)
- [apps/web/lib/api/workflows/check-workflow-conditions.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/check-workflow-conditions.ts)
## Overview
Workflow automation engines orchestrate complex partner lifecycle operations, reward calculations, and asynchronous event pipelines within affiliate programs. By combining trigger architectures, condition evaluation models, and asynchronous dispatch pipelines, the system automates multi-step processes such as partner application approvals, commission generation, customer reattributions, and discount management. This infrastructure ensures reliable background execution, handles fraud checks, reconciles balances, and processes historical backfills without blocking core API requests or degrading performance.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:164-180](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L164-L180), [apps/web/lib/api/workflows/execute-workflows.ts:1-43](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/execute-workflows.ts#L1-L43), [apps/web/lib/jobs/send-workflows.ts:1-80](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L1-L80)
## Trigger Architecture and Action Model
### Overview
Workflow triggers and conditional actions are structured around strongly-typed schemas, database models, and validation routines. A workflow execution context is built upon event types such as `partnerEnrolled`, `leadRecorded`, `saleRecorded`, and `commissionRecorded`, pairing partner metrics, program enrollments, and workspace identities with defined trigger parameters and action payloads.
Sources: [apps/web/lib/api/workflows/types.ts:28-50](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/types.ts#L28-L50)
### Trigger and Action Models
The database schema defines the core `Workflow` model alongside the `WorkflowTrigger` enum. The enum includes active triggers like `partnerEnrolled` and `partnerMetricsUpdated`, as well as legacy entries such as `clickRecorded`, `commissionEarned`, `leadRecorded`, and `saleRecorded` retained for backward compatibility. Each workflow links to a program, stores trigger conditions and actions as JSON fields, and supports indexing on `[programId, trigger]`.
| Model / Enum | Field Name | Type | Description |
| :--- | :--- | :--- | :--- |
| `WorkflowTrigger` | `partnerEnrolled` | Enum value | Triggered when a partner is enrolled (scheduled) |
| `WorkflowTrigger` | `partnerMetricsUpdated` | Enum value | Triggered when partner metrics are updated |
| `WorkflowTrigger` | `clickRecorded` | Enum value | Legacy trigger for recorded clicks |
| `WorkflowTrigger` | `commissionEarned` | Enum value | Legacy trigger for earned commissions |
| `Workflow` | `id` | String | Primary identifier |
| `Workflow` | `programId` | String | Foreign key to Program table |
| `Workflow` | `trigger` | WorkflowTrigger? | Optional trigger classification |
| `Workflow` | `triggerConditions` | Json | Serialized condition rules |
| `Workflow` | `actions` | Json | Serialized action payloads |
Sources: [apps/web/prisma/schema/workflow.prisma:1-29](https://github.com/blade47/dub/blob/HEAD/apps/web/prisma/schema/workflow.prisma#L1-L29)
### Action Types and Validation Schemas
Workflows support discriminated union action types validated through Zod schemas. Actions can award bounties, send campaigns, or move partners between groups based on defined parameters.
```typescript
export const workflowActionSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(WORKFLOW_ACTION_TYPES.AwardBounty),
data: z.object({
bountyId: z.string(),
}),
}),
z.object({
type: z.literal(WORKFLOW_ACTION_TYPES.SendCampaign),
data: z.object({
campaignId: z.string(),
}),
}),
z.object({
type: z.literal(WORKFLOW_ACTION_TYPES.MoveGroup),
data: z.object({
groupId: z.string(),
}),
}),
]);
```
Sources: [apps/web/lib/zod/schemas/workflows.ts:68-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/workflows.ts#L68-L89)
### Condition Evaluation Call-Chain
Condition evaluation follows a strict validation pipeline when checking workflow rules against input parameters. The execution walk proceeds through checking conditions, verifying attributes, matching operators, and running validators:
`checkWorkflowConditions()` → retrieves attributes via `WORKFLOW_TYPE_ATTRIBUTES` → validates condition attribute presence → resolves `WORKFLOW_OPERATORS` definition → validates operator compatibility against attribute definition → runs `operatorDefinition.validate()` on condition values.
```typescript
export function checkWorkflowConditions({
conditions,
workflowType,
}: {
conditions?: WorkflowCondition[] | null;
workflowType: WorkflowType;
}): {
valid: boolean;
errors: string[];
} {
if (!conditions || conditions.length === 0) {
return { valid: true, errors: [] };
}
const attributes = WORKFLOW_TYPE_ATTRIBUTES[workflowType];
const errors: string[] = [];
for (let i = 0; i < conditions.length; i++) {
const condition = conditions[i];
if (!condition?.attribute) {
errors.push(`Condition ${i + 1}: Please select an activity.`);
continue;
}
const attributeDefinition = attributes[condition.attribute as keyof typeof attributes];
if (!attributeDefinition) {
errors.push(`Condition ${i + 1}: Invalid activity.`);
continue;
}
const operatorDefinition = WORKFLOW_OPERATORS[condition.operator as keyof typeof WORKFLOW_OPERATORS];
if (!operatorDefinition) {
errors.push(`Condition ${i + 1}: Invalid operator.`);
continue;
}
if (!(attributeDefinition.operators as readonly string[]).includes(condition.operator)) {
errors.push(`Operator "${condition.operator}" is not valid for the activity "${condition.attribute}".`);
continue;
}
if (attributeDefinition.inputType === "none") {
continue;
}
if (condition.value == null) {
errors.push(`Condition ${i + 1}: Please enter a value.`);
continue;
}
try {
operatorDefinition.validate(condition.value as any);
} catch (error) {
errors.push(`Condition ${i + 1}: ${error instanceof Error ? error.message : "Invalid value."}`);
}
}
return { valid: errors.length === 0, errors };
}
```
Sources: [apps/web/lib/api/workflows/check-workflow-conditions.ts:8-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/check-workflow-conditions.ts#L8-L92)
> [!WARNING]
> Attributes with an input type of `"none"`, such as `partnerJoined`, intentionally skip value validation checks and exit early during condition evaluation.
Sources: [apps/web/lib/api/workflows/check-workflow-conditions.ts:67-70](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/check-workflow-conditions.ts#L67-L70)
### Attribute Definitions and Design Trade-offs
Workflow attributes define supported rule keys, input types, required data dependencies, and operator compatibility.
| Attribute Key | Label | Input Type | Allowed Operators | Data Requirements |
| :--- | :--- | :--- | :--- | :--- |
| `totalLeads` | total leads | number | `gte` | `partnerLinkStats` |
| `totalConversions` | total conversions | number | `gte` | `partnerLinkStats` |
| `totalSaleAmount` | total revenue | currency | `gte` | `partnerLinkStats` |
| `totalCommissions` | total commissions | currency | `gte` | `commissions` |
| `partnerEnrolledDays` | enrollment duration | dropdown | `gte` | none |
| `partnerJoined` | joins the program | none | `gte` | none |
| `partnerGroup` | group | group | `eq`, `ne`, `in`, `notIn` | none |
Sources: [apps/web/lib/api/workflows/attribute-definitions.ts:24-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/attribute-definitions.ts#L24-L92)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Discriminated unions for actions | Strict type safety and clear payload narrowing per action type | Requires explicit type mapping updates when adding new actions |
| Superset attribute definitions | Centralizes attribute rules across UI and API layers | Couples attribute keys to specific data fetching requirements |
| Standalone schema validation | Decouples condition checks from HTTP request lifecycles | Requires duplicate error context mapping for user-facing validation |
Sources: [apps/web/lib/api/workflows/attribute-definitions.ts:8-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/attribute-definitions.ts#L8-L92), [apps/web/lib/zod/schemas/workflows.ts:68-89](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/workflows.ts#L68-L89), [apps/web/lib/api/workflows/check-workflow-conditions.ts:8-92](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/check-workflow-conditions.ts#L8-L92)
## Workflow Ingestion and Dispatch Pipeline
### Overview
The workflow ingestion and dispatch pipeline manages the serialization, queueing, and delivery of asynchronous workflow trigger events to their respective execution endpoints. Trigger events are initialized through helper routines like `queuePartnerCommissionCreation()`, which fetches program enrollment records and dispatches payload data to Upstash QStash via `triggerWorkflows()`.
Sources: [apps/web/lib/partners/queue-partner-commission-creation.ts:6-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/queue-partner-commission-creation.ts#L6-L40), [apps/web/lib/jobs/send-workflows.ts:82-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L82-L136)
### Workflow Trigger Dispatch Call Chain
The execution pipeline processes asynchronous events by translating persisted job records into HTTP dispatch requests sent to Upstash QStash.
1. `queuePartnerCommissionCreation()` — Validates partner enrollment and constructs the commission creation parameters.
2. `dispatchWorkflows()` — Passes the structured job definition containing the workflow name and payload.
3. `triggerWorkflows()` — Chunks incoming jobs according to batch size limits and invokes the QStash client.
4. `buildTriggerRequest()` — Maps job names to route paths and applies flow control parameters.
Sources: [apps/web/lib/partners/queue-partner-commission-creation.ts:6-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/queue-partner-commission-creation.ts#L6-L40), [apps/web/lib/jobs/send-workflows.ts:62-136](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L62-L136)
### Supported Workflow Endpoints and Routing
Workflow names must conform to a strict kebab-case schema ending in `-workflow` and map directly to specific API route destinations.
| Workflow Name | Route Path |
| :--- | :--- |
| `partner-approved-workflow` | `/api/workflows/partner-approved` |
| `merge-partner-accounts-workflow` | `/api/workflows/merge-partner-accounts` |
| `create-partner-commission-workflow` | `/api/workflows/create-partner-commission` |
| `reattribute-customer-workflow` | `/api/workflows/reattribute-customer` |
| `detach-discount-workflow` | `/api/workflows/detach-discount` |
Sources: [apps/web/lib/jobs/send-workflows.ts:20-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L20-L34)
> [!WARNING]
> Workflow names are validated against the regular expression `/^[a-z][a-z0-9]*(-[a-z0-9]+)*-workflow$/`. Names failing to match this pattern or omitting the required `-workflow` suffix fail schema parsing during initialization.
Sources: [apps/web/lib/jobs/send-workflows.ts:20-25](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L20-L25)
### Action Execution and Context Resolution
Once workflow events reach execution endpoints, `executeWorkflows()` evaluates trigger conditions against aggregated partner metrics and dispatches actions through the `ACTION_HANDLERS` dictionary.
| Action Type | Handler Function |
| :--- | :--- |
| `AwardBounty` | `executeAwardBountyWorkflow` |
| `SendCampaign` | `executeSendCampaignWorkflow` |
| `MoveGroup` | `executeMoveGroupWorkflow` |
Sources: [apps/web/lib/api/workflows/execute-workflows.ts:23-35](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/execute-workflows.ts#L23-L35)
> [!TIP]
> Commission aggregations are evaluated lazily. The execution engine inspects workflow configuration conditions and skips expensive database aggregate queries unless `totalCommissions` is explicitly required by active rules.
Sources: [apps/web/lib/api/workflows/execute-workflows.ts:111-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/execute-workflows.ts#L111-L168)
### Pipeline Design Trade-offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Static workflow path mapping | Ensures strict compile-time validation between job names and HTTP endpoints | Requires manual registry updates when adding new workflow routes |
| Lazy commission aggregation | Avoids costly database queries when rules do not evaluate commission metrics | Introduces conditional branching complexity inside data loading promises |
| QStash chunked batching | Prevents payload size limit violations by splitting bulk dispatches | Requires error handling per chunk rather than individual transactions |
Sources: [apps/web/lib/api/workflows/execute-workflows.ts:111-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/execute-workflows.ts#L111-L168), [apps/web/lib/jobs/send-workflows.ts:27-34](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L27-L34), [apps/web/lib/jobs/send-workflows.ts:90-131](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/jobs/send-workflows.ts#L90-L131)
## Partner Commission Creation Pipeline
### Overview
Partner commission generation handles custom, lead, and sale rewards through a multi-step asynchronous pipeline. When a conversion event occurs, `queuePartnerCommissionCreation()` verifies partner enrollment and dispatches the payload to the `create-partner-commission-workflow` queue with flow control parallelism configured to `1` keyed by `partnerId`.
Sources: [apps/web/lib/partners/queue-partner-commission-creation.ts:6-40](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/queue-partner-commission-creation.ts#L6-L40), [apps/web/lib/openapi/commissions/create-commission.ts:1-35](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/openapi/commissions/create-commission.ts#L1-L35)
### Step-by-Step Commission Execution Walkthrough
The core commission generation process flows through several sequential verification and calculation steps inside `stepCreateCommission()`:
1. `stepCreateCommission()` — Receives the workflow input, normalizes missing numeric amounts to `0`, and filters out invalid raw event types such as `click` or `referral`.
2. `determinePartnerRewards()` — Evaluates program enrollment status, context, quantity, and link identifiers to match applicable reward rules and set initial earnings or reward configurations.
3. `prisma.commission.findFirst()` — Queries prior commission history for the specific partner and customer combination to determine whether the event is a new conversion or a recurring sale.
4. Fraud and Duplication Checks — Validates that previous commissions are not marked as `fraud` or `canceled`, prevents duplicate lead commissions for the same customer, and verifies subscription duration against `maxDuration` limits.
5. `executeSideEffects()` — Updates link and customer statistics within a Prisma transaction and invokes secondary asynchronous side effects like partner link stats synchronization and workflow executions.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:182-391](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L182-L391), [apps/web/lib/api/commissions/create-manual-commissions.ts:741-873](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/commissions/create-manual-commissions.ts#L741-L873)
> [!CAUTION]
> If a partner's prior first commission has a status of `fraud` or `canceled`, commission creation is immediately aborted to prevent cascading payouts on compromised customer accounts.
Sources: [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:313-319](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L313-L319)
### Pipeline Design Trade-offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Serialized workflow execution (`parallelism: 1`) | Prevents race conditions and duplicate commission entries for the same partner | Serializes concurrent conversions for a single partner, potentially increasing queue latency |
| Historical commission lookups | Enables accurate recurring sale attribution and max-duration checks | Adds database query overhead to every lead and sale evaluation |
| Dual Redis caching for lead events | Supports rapid deduplication and immediate access before Tinybird ingestion | Requires managing separate cache keys with expiration windows |
Sources: [apps/web/lib/partners/queue-partner-commission-creation.ts:25-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/queue-partner-commission-creation.ts#L25-L31), [apps/web/app/ee/api/workflows/create-partner-commission/route.ts:243-258](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/create-partner-commission/route.ts#L243-L258), [apps/web/lib/api/conversions/track-lead.ts:91-106](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L91-L106), [apps/web/lib/api/conversions/track-lead.ts:210-223](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/conversions/track-lead.ts#L210-L223)
## Partner Approval and Enrollment Workflows
### Overview
Partner approval and enrollment workflows orchestrate the onboarding lifecycle when a partner application is accepted into a program. The orchestration executes through `POST /api/workflows/partner-approved`, fetching program enrollment details and running six sequential actions ranging from default link creation to email notifications, webhook dispatch, bounty draft upserts, and Dub workflow executions.
Sources: [apps/web/app/ee/api/workflows/partner-approved/route.ts:34-51](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/partner-approved/route.ts#L34-L51), [apps/web/app/ee/api/workflows/partner-approved/route.ts:53-72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/partner-approved/route.ts#L53-L72)
### Partner Approval Execution Walkthrough
The partner approval handler coordinates several discrete steps within workflow contexts:
1. `getProgramEnrollmentOrThrow()` — Retrieves enrollment records including program, partner, and existing links using `programId` and `partnerId`.
2. `context.run("create-default-links", ...)` — Verifies partner group associations, fetches default links and UTM templates from `prisma.partnerGroup`, filters out already created links, and invokes `createPartnerDefaultLinks()`.
3. `context.run("create-discount-codes", ...)` — Fetches workspace configuration and calls `generateDiscountCodeForPartner()` if auto-provisioning is enabled.
4. `context.run("send-email", ...)` — Queries `prisma.partnerUser` for users opted into `applicationApproved` notifications, fetches reward configurations via `getGroupRewardsAndBounties()`, and dispatches batch emails using `sendBatchEmail()`.
5. `context.run("send-webhook", ...)` — Queries partner platforms via `prisma.partnerPlatform`, normalizes social media fields with `polyfillSocialMediaFields()`, and parses the enrolled partner schema.
6. Cron upsert job triggering — Invokes scheduled draft bounty submission upserts for performance-based bounties via QStash publishing when partner enrollments match commission-eligible statuses.
Sources: [apps/web/app/ee/api/workflows/partner-approved/route.ts:59-293](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/partner-approved/route.ts#L59-L293), [apps/web/app/ee/api/cron/bounties/upsert-draft-submissions/route.ts:235-244](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts#L235-L244)
> [!NOTE]
> Network programs designated by `NETWORK_PROGRAM_ID` short-circuit the enrollment workflow immediately after step 1, restricting execution solely to default link creation.
Sources: [apps/web/app/ee/api/workflows/partner-approved/route.ts:170-174](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/partner-approved/route.ts#L170-L174)
### Bounty Draft Submission Schema and Parameters
Cron jobs handling lifetime performance bounties parse request bodies against a strict Zod schema and query program enrollments with specific filters and pagination limits.
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| `bountyId` | `string` (required) | — | Unique identifier of the bounty being processed |
| `partnerIds` | `string[]` (optional) | `undefined` | Optional array of specific partner identifiers to filter scope |
| `page` | `number` (optional) | `0` | Pagination page offset for batching enrollment processing |
Sources: [apps/web/app/ee/api/cron/bounties/upsert-draft-submissions/route.ts:20-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts#L20-L26), [apps/web/app/ee/api/cron/bounties/upsert-draft-submissions/route.ts:39-143](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts#L39-L143)
> [!WARNING]
> If a bounty has not started yet and the time difference is 10 minutes or greater, or if the bounty type is not performance-based, submission creation is aborted with an early response.
Sources: [apps/web/app/ee/api/cron/bounties/upsert-draft-submissions/route.ts:68-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts#L68-L80)
## Customer Reattribution and Event Replay
### Customer Reattribution and Event Replay Overview
Customer reattribution transfers a customer and their historical tracking events and commissions from an old partner link to a new one. The workflow is orchestrated by `POST /api/workflows/reattribute-customer` via Upstash Workflow and executes seven sequential action steps ranging from event plan analysis and Tinybird event re-ingestion to link stat reconciliation, commission transfers, clawback generation, old event deletion, and old link stat decrementing.
Sources: [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:21-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L21-L33), [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:36-176](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L36-L176)
### Customer Reattribution Execution Walkthrough
The reattribution pipeline coordinates state adjustments across both analytical data stores (Tinybird) and relational records (Prisma) through the following call chain:
1. `load-plan` (`loadReattributeEventPlan()`) — Inspects old and new customer events in Tinybird to construct the reattribution event plan.
2. `reingest-events` (`reingestCustomerEvents()`) — Queries `prisma.link` using `newLinkId` and re-ingests events onto the new customer and link, handling `WorkflowRetryAfterError` with a 5-second backoff for transient failures.
3. `increment-new-link-stats` (`incrementNewLinkStats()`) — Updates metrics for the new link using the loaded reattribution plan and increment options.
4. `transfer-unpaid-commissions` (`transferUnpaidCommissions()`) — Moves pending, hold, and processed commissions from the old partner context to the new partner context.
5. `load-clawback-plan` & `optional-clawback` (`loadClawbackPlan()` & `applyClawbackAndReplacementCommissions()`) — Optionally calculates clawbacks for paid earnings on the old partner, clears invoice/event identifiers if required, queues negative tracking error commissions, and recreates valid lead or sale replacement commissions under the new partner.
6. `delete-old-events` (`deleteTinybirdCustomerEvents()`) — Purges historical customer events from Tinybird for `oldCustomerId`, throwing a `WorkflowRetryAfterError` with a 10-second backoff upon failure.
7. `decrement-old-link-stats` (`decrementOldLinkStats()`) — Decrements statistics and conversions on the old link based on the loaded plan.
Sources: [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:40-176](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L40-L176)
### Reattribution Workflow Parameters and Constants
The customer reattribution engine relies on specific limits, status categories, and schema configurations defined across the API library and workflow routes.
| Constant / Parameter | Type / Value | Description |
| :--- | :--- | :--- |
| `CUSTOMER_REATTRIBUTION_EVENTS_LIMIT` | `number` (`500`) | Maximum number of events processed during customer reattribution |
| `UNPAID_COMMISSION_STATUSES` | `readonly string[]` (`["pending", "hold", "processed"]`) | Commission statuses eligible for direct transfer during reattribution |
| `STATS_LOCK_TTL_SECONDS` | `number` (`86400`) | Time-to-live in seconds for statistics synchronization locks (24 hours) |
| `WorkflowRetryAfterError` (reingest) | `Error`, `"5s"` backoff | Exception thrown to retry event re-ingestion on failure |
| `WorkflowRetryAfterError` (delete) | `Error`, `"10s"` backoff | Exception thrown to retry Tinybird event deletion on failure |
Sources: [apps/web/lib/api/customers/reattribute-customer.ts:22-24](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L22-L24), [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:77-80](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L77-L80), [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:156-161](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L156-L161)
### Reattribution Design Trade-Offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Asynchronous multi-step workflow execution via Upstash | Ensures durable retries and decoupled execution for long-running analytics re-ingestion | Increases total completion latency due to step boundary serialization |
| Distributed run locking (`runOnce`) on clawback calculations | Prevents duplicate clawback generation and race conditions on financial adjustments | Requires persistent coordination state via Redis/Prisma |
| Event re-ingestion followed by old event deletion | Avoids temporary data loss windows during customer context switching | Briefly duplicates analytical event counts across partners during migration |
Sources: [apps/web/lib/api/customers/reattribute-customer.ts:741-771](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/customers/reattribute-customer.ts#L741-L771), [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:47-163](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L47-L163)
> [!WARNING]
> If a re-ingestion attempt encounters an explicit `ZodError` or an error message containing `"too many events to reattribute"`, the workflow immediately rejects and bypasses automatic retry handlers.
Sources: [apps/web/app/ee/api/workflows/reattribute-customer/route.ts:68-76](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/reattribute-customer/route.ts#L68-L76)
## Discount Detachment and Remapping
### Overview
The discount detachment workflow handles the asynchronous disassociation of discounts from program enrollments, link rewards, and code remapping. Soft-deleted discounts with their `programId` cleared are cleaned up through a structured pipeline that runs disassociations in parallel before initiating code remapping. Hard-deletion is deferred to an orphaned cleanup cron route once remapping finishes and no remaining entities reference the soft-deleted discount.
Sources: [apps/web/app/ee/api/workflows/detach-discount/route.ts:19-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts#L19-L30)
### Workflow Execution Call-Chain and Steps
The `POST` handler exposed by `@upstash/workflow/nextjs` processes input matching `inputSchema` (`programId` and `discountId` string fields) through several ordered execution blocks.
1. `validate-discount` (`prisma.discount.findUnique`) — Queries the database for the discount by `discountId`, selecting `id` and `programId`. If the discount is not found or `programId` is not `null`, the workflow returns early and skips execution.
2. `detach-discount-from-enrollments` & `detach-discount-from-link-rewards` (`Promise.all`) — Runs `detachDiscountFromProgramEnrollments()` and `detachDiscountFromLinkRewards()` concurrently using `context.run`.
3. `remap-discount-codes` (`dispatchRemapDiscountCodes`) — Dispatches remapping jobs for discount codes associated with the discount after enrollments and link rewards are fully updated.
Sources: [apps/web/app/ee/api/workflows/detach-discount/route.ts:12-107](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts#L12-L107)
> [!WARNING]
> If a discount is queried during validation and its `programId` is still present (not `null`), the workflow halts execution and skips the detachment pipeline.
Sources: [apps/web/app/ee/api/workflows/detach-discount/route.ts:55-60](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts#L55-L60)
### Failure Handling and Logging
When workflow execution encounters an unhandled exception or failure, the Upstash workflow invokes the configured `failureFunction`. This captures context metadata and flushes error details through Axiom logging.
```typescript
failureFunction: async ({
context,
failStatus,
failResponse,
failHeaders,
}) => {
logger.error("workflow.failed", {
service: "qstash",
event: "workflow.failed",
workflowType: "detach-discount",
workflowRunId: context.workflowRunId,
discountId: context.requestPayload?.discountId,
programId: context.requestPayload?.programId,
failStatus,
failResponse,
failHeaders,
});
await logger.flush();
},
```
Sources: [apps/web/app/ee/api/workflows/detach-discount/route.ts:109-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts#L109-L130)
> [!NOTE]
> Hard-deletion of the discount record is never performed directly within this workflow; it is delegated entirely to `/api/cron/cleanup/orphaned` after all references have been successfully disassociated.
Sources: [apps/web/app/ee/api/workflows/detach-discount/route.ts:27-29](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/workflows/detach-discount/route.ts#L27-L29)
## Referral Commissions and Backfill Batches
### Overview
The referral commission pipeline handles scheduled cron jobs and batch workers that evaluate referral reward triggers, enforce duration caps, verify commission thresholds, and execute historical backfills for partner programs.
Sources: [apps/web/app/ee/api/cron/commissions/referrals/create/route.ts:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts#L1-L26), [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts:1-135](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts#L1-L135)
### Referral Commission Execution Walkchain
The referral commission generation process flows through specific validation and calculation functions when triggered via cron:
1. `POST` (`/api/cron/commissions/referrals/create`) — Parses the incoming raw request body using `inputSchema` (accepting either `{ sourceCommissionId }` or `{ programId, partnerId }`).
2. `createReferralCommission` (`/lib/partner-referrals/create-referral-commission.ts`) — Resolves referral context, checks for self-referrals (`partnerId === referredByPartnerId`), ignores the network program ID (`NETWORK_PROGRAM_ID`), and retrieves the referrer's `ProgramEnrollment` and `referralReward`.
3. `referralRewardConfigSchema.safeParse` — Validates the reward configuration structure and extracts the `trigger` and `commissionsThresholdInCents`.
4. Trigger Evaluation & Calculation — Computes earnings based on the specific reward trigger type (`commissionEarned`, `saleRecorded`, `partnerApproved`, or `commissionThreshold`), checking `maxDuration` constraints via `differenceInMonths` when applicable.
5. `prisma.commission.create` — Persists the new referral commission with `type: CommissionType.referral` and a unique `invoiceId`.
Sources: [apps/web/app/ee/api/cron/commissions/referrals/create/route.ts:8-17](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/create/route.ts#L8-L17), [apps/web/lib/partner-referrals/create-referral-commission.ts:18-237](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts#L18-L237)
### Referral Reward Triggers and Configuration
Referral rewards support distinct triggers defined in `referralRewardConfigSchema`.
| Trigger Identifier | Required Configuration | Behavior & Evaluation |
| :--- | :--- | :--- |
| `commissionEarned` | `trigger` | Calculates referral earnings as a percentage of the source commission's earnings. |
| `saleRecorded` | `trigger` | Calculates referral earnings as a percentage of the source commission's sale amount. |
| `partnerApproved` | `trigger` | Awards a flat amount (`amountInCents`) upon partner approval. |
| `commissionThreshold` | `trigger`, `commissionsThresholdInCents` | Aggregates earned sale commissions for the partner; grants reward if total meets or exceeds `commissionsThresholdInCents`. |
Sources: [apps/web/lib/partner-referrals/create-referral-commission.ts:99-190](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts#L99-L190), [apps/web/lib/zod/schemas/rewards.ts:495-511](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/rewards.ts#L495-L511)
> [!WARNING]
> Self-referrals where `partnerId` matches `referredByPartnerId` and creation requests targeting the network program (`NETWORK_PROGRAM_ID`) are explicitly intercepted and skipped.
Sources: [apps/web/lib/partner-referrals/create-referral-commission.ts:30-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/create-referral-commission.ts#L30-L42)
### Historical Backfill Pipeline
The backfill cron route (`/api/cron/commissions/referrals/backfill`) queries `ProgramApplicationEvent` and `ProgramEnrollment` to check historical events for a partner.
- For `commissionThreshold` and `partnerApproved` triggers, it enqueues a single batch job to `/api/cron/commissions/referrals/create`.
- For `saleRecorded` and `commissionEarned` triggers, it fetches up to 50 eligible sale commissions at a time (`status` in `pending`, `processed`, `paid`) and enqueues individual creation jobs for each source commission ID via `enqueueBatchJobs` and `chunk`.
Sources: [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts:12-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts#L12-L130)
> [!IMPORTANT]
> If a referrer's reward configuration uses an unsupported or unrecognized trigger, the backfill endpoint returns early with a skip response.
Sources: [apps/web/app/ee/api/cron/commissions/referrals/backfill/route.ts:132-135](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/commissions/referrals/backfill/route.ts#L132-L135)
## Related
- [[Background Jobs and Queues]]
- [[Commission Rules and Rewards]]
---
## Technical docs: Campaign Broadcaster
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/campaign-broadcaster
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/cron/campaigns/broadcast/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts)
- [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts)
- [apps/web/lib/api/workflows/send-campaign/execute.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts)
- [apps/web/app/ee/api/cron/campaigns/queue-scheduled/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts)
- [apps/web/app/ee/api/campaigns/campaignId/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/campaign-editor.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-editor.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/use-campaign-confirmation-modals.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/use-campaign-confirmation-modals.tsx)
- [apps/web/scripts/send-batch-emails.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/send-batch-emails.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/campaign-controls.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-controls.tsx)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts)
- [packages/email/src/templates/campaign-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx)
- [packages/email/src/templates/broadcasts/dub-product-update-summer26.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-product-update-summer26.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/layout.tsx)
- [packages/email/src/templates/broadcasts/dub-product-update-mar26.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-product-update-mar26.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-table.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-table.tsx)
- [apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaignId/send-email-preview-modal.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx)
- [apps/web/app/ee/api/cron/program-application-reminder/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-page-content.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-page-content.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-1.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-1.tsx)
- [apps/web/lib/email/run-trial-email-cron.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/run-trial-email-cron.ts)
- [apps/web/lib/payouts/send-payout-reminder.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/payouts/send-payout-reminder.ts)
- [packages/email/src/templates/broadcasts/dub-startup-program-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/dub-startup-program-announcement.tsx)
- [packages/email/src/templates/program-payout-reminder.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/program-payout-reminder.tsx)
- [packages/email/src/templates/broadcasts/payout-auto-withdrawals.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/payout-auto-withdrawals.tsx)
- [packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/program-marketplace-announcement.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-3.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-3.tsx)
- [packages/email/src/templates/broadcasts/launch-week-day-2.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/broadcasts/launch-week-day-2.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/campaigns/campaigns-upsell.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/campaigns-upsell.tsx)
## Overview
The Campaign Broadcaster subsystem provides an authoring and asynchronous delivery engine for partner marketing and transactional email campaigns within Dub. It solves the operational challenge of scaling high-volume communications to affiliate cohorts by decoupling campaign creation from execution via robust scheduling queues and rate-limited dispatchers. Key design decisions include QStash-driven batch chunking, signature-verified cron triggers, and dynamic HTML template interpolation that maps partner-specific metadata and reward structures into responsive emails. By integrating directly with workspace authorization controls, programmatic REST APIs, and targeted workflow rule evaluators, the broadcaster enables reliable, auditable message delivery across segmented partner networks.
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:1-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L1-L56), [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:36-63](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L36-L63), [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:18-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L18-L56)
## Campaign Composition and Authoring Surface
### Overview
The Campaign Composition and Authoring Surface provides the user interface and form context for creating and editing affiliate marketing and transactional campaigns. It uses React Hook Form wrapped by specialized form contexts to manage complex state such as rich text composition, audience recipient group and tag selection, and custom sender address formatting. Editors are safeguarded by dynamic state validation rules that prevent unauthorized edits based on live campaign statuses, locking down fields and presenting clear status-driven error messages when modifications are prohibited.
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:1-61](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/campaign-editor.tsx#L1-L61), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:1-13](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L1-L13)
### Form Context and State Management
The authoring surface builds upon `react-hook-form` integrated with state management utilities like `useCampaignFormContext` and confirmation modal hooks (`useCampaignConfirmationModals`). Campaign modifications trigger asynchronous mutations via `useApiMutation` pointing to `/api/campaigns/[campaignId]`, with state cache invalidations handled through SWR prefix mutations (`mutatePrefix`).
> [!NOTE]
> Campaign editability is dynamically controlled by the `status` field. If a campaign enters sending, sent, canceled, or active states, edits are blocked to ensure transactional integrity during delivery.
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L1-L50), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:102-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx#L102-L108)
### Campaign Lifecycle Actions and Confirmations
The authoring interface coordinates critical publication and state transition flows through confirmation modals. The publishing and scheduling workflow evaluates target partner counts derived from selected groups and tags before submitting updates.
```mermaid
sequenceDiagram
participant User
participant Editor as Campaign Editor
participant Modal as Confirmation Modal
participant API as REST API (/api/campaigns)
User->>Editor: Click Publish or Schedule
Editor->>Modal: Open confirmation dialog with recipient count
User->>Modal: Confirm action
Modal->>API: PATCH campaign data with new status
API-->>Editor: Mutation success & SWR cache update
Editor->>User: Toast notification & redirect to campaign list
```
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx:51-112](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/use-campaign-confirmation-modals.tsx#L51-L112)
### Campaign Status Restrictions
| Campaign Status | Edit Permission | Behavior / Restriction Message |
|-----------------|-----------------|----------------------------------|
| `sending` | Locked | Edits aren't allowed while sending. |
| `sent` | Locked | Edits aren't allowed after sending. |
| `canceled` | Locked | Edits aren't allowed after cancellation. |
| `active` | Locked | Edits aren't allowed while the campaign is active. Pause the campaign to make changes. |
| `draft` / `paused` | Editable | Full form controls and state adjustments permitted. |
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx:102-108](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx#L102-L108)
## Campaign Preview and Test Delivery
### Overview
The campaign preview and test delivery subsystem allows workspace members to verify campaign email templates by dispatching live test renders to designated email addresses. Editors interact with the interface via the `SendEmailPreviewModal` component, which manages recipient input and validates form state before submitting requests to the preview REST endpoint.
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:11-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L11-L33)
### Preview Modal and Form Integration
The `SendEmailPreviewModal` hook and component extract current form values (`subject`, `preview`, `bodyJson`, and `from`) directly from the campaign form context using `useWatch`. Users supply comma-separated email addresses, which are parsed and cleaned prior to dispatch.
```typescript
export function useSendEmailPreviewModal({
campaignId,
}: {
campaignId: string;
}) {
const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] =
useState(false);
const SendEmailPreviewModalCallback = useCallback(
() => (
),
[showSendEmailPreviewModal, campaignId],
);
return {
showSendEmailPreviewModal,
setShowSendEmailPreviewModal,
SendEmailPreviewModal: SendEmailPreviewModalCallback,
};
}
```
Sources: [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:29-74](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L29-L74), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:130-154](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx#L130-L154)
> [!WARNING]
> Preview test requests enforce a strict recipient limit of 10 email addresses per call. Submitting empty subjects or missing body content triggers client-side validation errors via `sonner` toasts.
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:30-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L30-L34), [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx:37-47](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/campaigns/%5BcampaignId%5D/send-email-preview-modal.tsx#L37-L47)
### API Route Execution and Template Rendering
The backend route handler validates workspace authentication, plan requirements (`advanced` or `enterprise`), and member roles (`owner` or `member`) via `withWorkspace`. It validates the request body using `sendPreviewEmailSchema`, fetching program and campaign records concurrently.
```mermaid
sequenceDiagram
participant Modal as SendEmailPreviewModal
participant API as POST /api/campaigns/[campaignId]/preview
participant Resend as sendBatchEmail
participant Template as CampaignEmail
Modal->>API: POST payload (subject, preview, bodyJson, from, emailAddresses)
API->>API: Parse body & verify workspace program & campaign
API->>API: Validate "from" address against verified email domains
API->>Template: Render CampaignEmail with interpolated variables
API->>Resend: Dispatch batch email with "[TEST]" subject prefix
Resend-->>API: Resend response / error status
API-->>Modal: JSON success or bad_request error
```
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:37-81](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L37-L81), [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:129-133](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L129-L133)
> [!NOTE]
> Custom `from` addresses are parsed with `parseCampaignFromAddress` and checked against the program's verified email domains. If unverified, the request throws a `bad_request` DubApiError.
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts:65-81](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L65-L81)
### Campaign Email Template Structure
The `CampaignEmail` component uses `@react-email/components` and Tailwind styling to construct responsive email markup. It dynamically inserts program logos, program names, campaign preview text, and rendered body HTML.
| Template Property | Source Path | Behavior |
|-------------------|-------------|----------|
| `CAMPAIGN_EMAIL_TEXT_COLOR` | `packages/email/src/templates/campaign-email.tsx` | Fixed hex color `#000000` applied to email body text and list items. |
| `Preview` | `campaign.preview` | Injected as preheader snippet text if provided. |
| `Logo` | `program.logo` | Defaults to `https://assets.dub.co/wordmark.png` if null. |
| `Reply in Dub` | `program.messagingEnabledAt` & campaign type | Appends a reply CTA block for transactional campaigns when messaging is active. |
| Footer Link | Campaign type check (`marketing`) | Appends marketing unsubscription notification link for marketing campaigns. |
Sources: [packages/email/src/templates/campaign-email.tsx:18-124](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L18-L124)
## Campaign API Surface and Lifecycle
### Overview
The Campaign API surface manages campaign configuration, audience groups, partner tags, schedule triggers, and transactional workflows through REST endpoints secured by workspace authentication. Handlers enforce required plans (`advanced` or `enterprise`) and member roles (`owner` or `member`) via `withWorkspace`, resolving the default program ID before executing database operations.
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:1-50](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L1-L50), [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:216-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L216-L220)
### Route Lifecycle and State Transitions
The `PATCH /api/campaigns/[campaignId]` route validates incoming campaign payloads, checks workflow conditions, manages partner groups and tags, and updates the database inside a Prisma transaction. When a campaign transitions into a due marketing broadcast state, it enqueues a QStash background job via Vercel `waitUntil`.
```mermaid
sequenceDiagram
participant Client as REST Client
participant PATCH as PATCH /api/campaigns/[campaignId]
participant DB as Prisma Transaction
participant QStash as QStash Publisher
Client->>PATCH: Request payload (name, status, scheduledAt, groupIds, etc.)
PATCH->>PATCH: validateCampaign() & validateWorkflowConditions()
PATCH->>PATCH: Check groupIds & partnerTagIds array equality
PATCH->>DB: Execute transaction (update workflow & campaign records)
DB-->>PATCH: Return updatedCampaign
PATCH->>PATCH: shouldEnqueueDueMarketingBroadcast(previous, next)
alt Due marketing broadcast triggered
PATCH->>QStash: qstash.publishJSON() via waitUntil()
end
PATCH-->>Client: Return JSON CampaignSchema response
```
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:52-220](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L52-L220)
> [!NOTE]
> Group ID and partner tag updates evaluate array equality against existing relations using `arrayEqual` and `pluck`. Passing `null` for `groupIds` is explicitly treated as an empty array targeting all groups, whereas `null` for `partnerTagIds` signifies no tag restrictions.
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:100-130](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L100-L130)
### Marketing Broadcast Evaluation
The broadcast scheduling logic relies on helper functions in `marketing-campaign-broadcast.ts` to determine whether a campaign qualifies as a due marketing broadcast that requires immediate queue dispatching.
| Function Name | Parameters | Return Condition |
|---------------|------------|------------------|
| `isDueMarketingCampaign` | `{ campaign: MarketingBroadcastCampaign, now?: Date }` | Returns true if `campaign.type === CampaignType.marketing`, `campaign.status === CampaignStatus.scheduled`, and `(!campaign.scheduledAt || campaign.scheduledAt <= now)`. |
| `shouldEnqueueDueMarketingBroadcast` | `{ previous: MarketingBroadcastCampaign, next: MarketingBroadcastCampaign, now?: Date }` | Returns true if `next` is a due marketing campaign while `previous` was not. |
Sources: [apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts:8-35](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/campaigns/marketing-campaign-broadcast.ts#L8-L35)
> [!TIP]
> Background dispatching uses QStash flow control with a parallelism limit of 1 keyed by `broadcast-marketing-campaign-${campaignId}` to prevent duplicate broadcast executions during concurrent schedule updates.
Sources: [apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts:201-204](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts#L201-L204)
## Scheduled Campaign Queue Dispatching
### Overview
Cron-driven scheduled campaign queue dispatching handles fan-out operations for due marketing broadcasts and scheduled transactional workflows. The cron endpoint `GET /api/cron/campaigns/queue-scheduled` runs under `withCron` with a forced dynamic configuration and a maximum execution duration of 600 seconds. It fans out concurrently using `Promise.allSettled` to execute `queueTransactionalCampaigns(now)` and `queueMarketingCampaigns(now)`.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:1-26](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L1-L26)
### Call-Chain Execution Walkthrough
The cron handler evaluates both campaign categories, accumulating any failures via `isRejected` and `serializeError`. If both queues return zero items, it reports no campaigns to queue; otherwise, it reports the count of successfully queued marketing and transactional campaigns.
```mermaid
sequenceDiagram
participant Cron as GET /api/cron/campaigns/queue-scheduled
participant Trans as queueTransactionalCampaigns()
participant Mkt as queueMarketingCampaigns()
participant DB as Prisma Database
participant QStash as enqueueBatchJobs()
Cron->>Trans: queueTransactionalCampaigns(now)
Trans->>Trans: isTransactionalTick(now)
alt Within 12h tick window
loop Paginated by CRON_BATCH_SIZE
Trans->>DB: prisma.campaign.findMany(transactional, active)
DB-->>Trans: campaigns list
Trans->>Trans: filter scheduled workflows via isScheduledWorkflow()
Trans->>QStash: enqueueBatchJobs(scheduledWorkflows)
end
end
Cron->>Mkt: queueMarketingCampaigns(now)
loop Paginated by CRON_BATCH_SIZE
Mkt->>DB: prisma.campaign.findMany(marketing, scheduled)
DB-->>Mkt: campaigns list
Mkt->>QStash: enqueueBatchJobs(broadcast-marketing-campaign)
end
Cron-->>Cron: Aggregate status and return logAndRespond
```
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:20-178](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L20-L178)
> [!WARNING]
> Marketing campaigns with a `sending` status are never reclaimed by the cron queue. Failures trigger Slack alerts so administrators can resume them manually.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:139-141](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L139-L141)
### Queue Batching and Flow Control Configuration
Both queue functions page through database records using `CRON_BATCH_SIZE` and process items in ascending campaign ID order. When batching jobs for QStash delivery via `enqueueBatchJobs`, each queue item configures specific target endpoints, deduplication IDs, and flow control limits.
| Queue Operation | Target URL | Flow Control Key | Parallelism | Deduplication ID |
|-----------------|------------|------------------|-------------|------------------|
| Transactional Workflow | `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${workflow.id}` | `execute-scheduled-workflow` | 10 | `workflow.id` |
| Marketing Broadcast | `${APP_DOMAIN_WITH_NGROK}/api/cron/campaigns/broadcast` | `broadcast-marketing-campaign-${campaign.id}` | 1 | None |
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:111-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L111-L121), [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:159-170](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L159-L170)
> [!TIP]
> Transactional campaigns check `isTransactionalTick(now)`, which restricts execution to the first 5 minutes of 00:00 or 12:00 UTC. QStash deduplication lasts 10 minutes, absorbing Vercel cron jitter without leaking duplicate publishes.
Sources: [apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts:58-69](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts#L58-L69)
## Broadcast Execution and Email Rendering
### Overview
The broadcast execution engine handles the ingestion, sender verification, database batching, and template rendering of marketing campaigns triggered by QStash. Operating under a force-dynamic route configuration, the broadcast endpoint parses the incoming request body against a strict Zod schema enforcing `campaignId`, optional `startingAfter` cursor pagination, and a `batchNumber` defaulting to `1`.
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:28-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L28-L36), [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:43-56](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L43-L56)
### Call-Chain Execution Walkthrough
When QStash triggers `/api/cron/campaigns/broadcast`, the handler executes a structured validation, concurrency claim, and batch processing sequence.
```mermaid
sequenceDiagram
participant QStash as QStash Webhook
participant Route as POST /api/cron/campaigns/broadcast
participant DB as Prisma Database
participant Email as @dub/email (sendBatchEmail)
QStash->>Route: POST request with rawBody & QStash-Signature
Route->>Route: verifyQstashSignature()
Route->>Route: schema.parse(JSON.parse(rawBody))
Route->>DB: prisma.campaign.findUnique(campaignId)
DB-->>Route: campaign & program emailDomains
Route->>Route: Validate status (scheduled/sending) & schedule time (< 5 min diff)
alt First batch (no startingAfter)
Route->>DB: prisma.campaign.updateMany (claim status to sending & qstashMessageId)
DB-->>Route: claimed count
end
Route->>Route: cancelCampaignIfInvalidFromAddress()
Route->>DB: prisma.programEnrollment.findMany (take: EMAIL_BATCH_SIZE, skip cursor)
DB-->>Route: programEnrollments list
loop For each enrollment recipient
Route->>Route: resolveCampaignEmailVariables() & renderCampaignEmailHTML()
end
Route->>Email: sendBatchEmail(renderedBatches)
alt More enrollments remain
Route->>QStash: qstash.publishJSON() for next batch
end
Route-->>QStash: Log and respond success
```
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:45-227](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L45-L227)
> [!WARNING]
> If a campaign is scheduled to broadcast 5 or more minutes in the future, the broadcast execution skips immediately to prevent premature dispatching caused by scheduling errors.
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:94-106](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L94-L106)
### Broadcast Configuration Constants and Limits
The broadcast subsystem relies on fixed batch sizes and delay thresholds to maintain deliverability and respect rate limits when sending bulk partner communications.
| Constant Name | Value | Purpose |
|---------------|-------|---------|
| `EMAIL_BATCH_SIZE` | `100` | Number of partner enrollments retrieved and processed per execution batch. |
| `BATCH_DELAY_SECONDS` | `2` | Standard delay interval scheduled between consecutive broadcast batches. |
| `EXTENDED_DELAY_SECONDS` | `30` | Extended delay interval applied after reaching the batch threshold. |
| `EXTENDED_DELAY_INTERVAL` | `25` | Number of batches after which the extended delay is triggered. |
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:38-41](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L38-L41)
> [!NOTE]
> Concurrency collisions are prevented when `startingAfter` is absent by checking the `Upstash-Message-Id` header and claiming the campaign status from `scheduled` to `sending` via an atomic `updateMany` query.
Sources: [apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts:114-136](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts#L114-L136)
### Email Template Rendering and Styling
The campaign email template uses `@react-email/components` paired with Tailwind CSS styling to render responsive marketing and transactional emails. The base color constant `CAMPAIGN_EMAIL_TEXT_COLOR` is explicitly set to `#000000` and applied inline to list items and HTML body containers.
```tsx
export const CAMPAIGN_EMAIL_TEXT_COLOR = "#000000";
export default function CampaignEmail({
program = {
name: "Acme",
slug: "acme",
logo: "https://assets.dub.co/misc/acme-logo.png",
messagingEnabledAt: new Date(),
},
campaign = {
type: "marketing",
preview: "Test Preview",
body: `
{campaign.type === "marketing" && (
Don't want to receive marketing emails from any programs on
Dub?{" "}
Update your notification settings here.
)}
);
}
```
Sources: [packages/email/src/templates/campaign-email.tsx:18-124](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L18-L124)
> [!TIP]
> Transactional campaigns with `messagingEnabledAt` render an explicit "Reply in Dub" button block, whereas marketing campaigns append a standard unsubscribe and notification preferences footer.
Sources: [packages/email/src/templates/campaign-email.tsx:92-118](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/campaign-email.tsx#L92-L118)
## Targeted Workflow Campaign Conditions
### Overview
Targeted campaigns executed through automated workflow steps rely on granular condition evaluation, cohort group mapping, and partner link aggregation. When an execution context targets specific partner identities or broad program enrollments, campaign routing matches recipient attributes against campaign configuration parameters.
### Workflow Execution and Rule Evaluation
The execution pipeline for send-campaign workflows validates action types, retrieves campaign eligibility parameters, and resolves target enrollments. The call chain for dispatching targeted campaign steps follows a precise resolution sequence:
`executeSendCampaignWorkflow()` → `parseWorkflowConfig()` → `prisma.campaign.findUnique()` → `resolveProgramEnrollment()` / `resolveProgramEnrollments()` → deduplication against `prisma.notificationEmail.findMany()` → `chunk()` → `sendBatchEmail()`.
```typescript
export const executeSendCampaignWorkflow = async ({
workflow,
context,
}: {
workflow: Workflow;
context?: WorkflowContext;
}) => {
const { conditions, action } = parseWorkflowConfig(workflow);
if (action.type !== WORKFLOW_ACTION_TYPES.SendCampaign) {
console.log(
`Workflow ${workflow.id} is not a send campaign workflow: ${action.type}`,
);
return;
}
const { campaignId } = action.data;
const { programId, partnerId } = context?.identity || {
programId: workflow.programId,
partnerId: undefined,
};
...
```
Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:43-63](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L43-L63)
> [!WARNING]
> Duplicate prevention is enforced prior to batch rendering by querying existing `NotificationEmail` records with type `"Campaign"` for the target campaign and partner IDs, removing any previously notified partners from the active execution chunk.
Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:122-149](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L122-L149)
### Cohort Filtering and Partner Link Parameters
Workflows extract group IDs and partner tag IDs directly from campaign relations to filter matching program enrollments. The table below outlines the core components and payloads governing workflow campaign evaluations.
| Parameter / Type | Source Entity | Purpose |
| :--- | :--- | :--- |
| `conditions` | `WorkflowCondition[]` | Evaluates custom rules via `evaluateWorkflowConditions` during enrollment resolution. |
| `campaignGroupIds` | `string[]` | Extracted via `pluck(campaign.groups, "groupId")` to scope recipients to specific partner groups. |
| `campaignPartnerTagIds` | `string[]` | Extracted via `pluck(campaign.partnerTags, "partnerTagId")` to target tagged partner cohorts. |
| `alreadySentPartnerIds` | `string[]` | Dedupes recipients by checking existing `NotificationEmail` records for the target campaign. |
| `programEnrollmentsChunks` | `Prisma.ProgramEnrollmentGetPayload[][]` | Splits resolved enrollments into batches of 100 via `chunk(programEnrollments, 100)` for delivery. |
Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:3-110](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L3-L110), [apps/web/lib/api/workflows/send-campaign/execute.ts:123-168](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L123-L168)
> [!TIP]
> Partner links and performance metrics can be aggregated alongside campaign runs using `aggregatePartnerLinksStats` to feed downstream analytics and variable interpolation.
Sources: [apps/web/lib/api/workflows/send-campaign/execute.ts:4](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/api/workflows/send-campaign/execute.ts#L4)
## Related
- [[Email Templates and Delivery]]
- [[Partner Program Management]]
---
## Technical docs: GET Get top programs by sales
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/gettopprogramsbysales
## Parameters
## Responses
## Try It
---
## Technical docs: Email Templates and Delivery
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/email-templates-and-delivery
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/app/ee/api/cron/send-batch-email/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts)
- [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts)
- [apps/web/app/api/resend/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts)
- [packages/email/src/templates/webhook-failed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx)
- [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts)
- [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts)
- [packages/email/src/templates/webhook-disabled.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx)
- [apps/web/app/ee/api/embed/referrals/tremendous/send-otp/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/embed/referrals/tremendous/send-otp/route.ts)
- [packages/email/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts)
- [apps/web/lib/partners/create-stripe-transfer.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partners/create-stripe-transfer.ts)
- [packages/email/src/templates/webhook-added.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx)
- [packages/email/src/send-via-nodemailer.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts)
- [apps/web/lib/email/queue-batch-email.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts)
- [packages/email/src/templates/lead-status-updated.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/lead-status-updated.tsx)
- [packages/email/src/templates/new-submitted-lead-comments-from-program.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-program.tsx)
- [apps/web/app/ee/api/cron/email-domains/verify/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts)
- [apps/web/app/ee/api/cron/payouts/process/process-payouts.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts)
- [packages/email/src/resend/client.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts)
- [packages/email/src/templates/partner-tremendous-verify-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-tremendous-verify-email.tsx)
- [packages/email/src/templates/verify-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx)
- [apps/web/lib/email/render-trial-email.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/render-trial-email.tsx)
- [apps/web/lib/webhook/failure.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts)
- [packages/email/src/templates/email-domain-status-changed.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/email-domain-status-changed.tsx)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/domains/email/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/domains/email/page-client.tsx)
- [apps/web/lib/email/email-templates-map.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/email-templates-map.ts)
- [packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/partners/invite-email-preview.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/partners/invite-email-preview.tsx)
- [packages/email/src/templates/new-message-from-partner.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx)
- [packages/email/src/templates/feedback-email.tsx](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/feedback-email.tsx)
## Overview
The email templates and delivery system provides a robust, multi-transport dispatch infrastructure built around React Email component composition and automated background queues. It unifies outbound messaging across production and development environments by switching dynamically between Resend and Nodemailer transports, validating recipient restrictions, and handling asynchronous batch processing via QStash. The architecture also integrates custom email domain verification routines, interactive campaign preview flows, and webhook ingestion listeners to alert workspace owners of delivery failures.
Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:4-5](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L4-L5), [packages/email/src/send-via-resend.ts:8-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L33), [packages/email/src/index.ts:6-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L6-L24), [apps/web/lib/email/queue-batch-email.ts:14-16](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L14-L16), [apps/web/app/ee/api/cron/email-domains/verify/route.ts:21-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L21-L36)
## Core Email Dispatch Architecture
### Core Email Dispatch Architecture
The public export surface of the email package handles outbound communication through two primary entry points: `sendEmail` and `sendBatchEmail`. Both functions implement a fallback dispatch pattern that checks for an initialized Resend client before falling back to an SMTP configuration via Nodemailer. If neither transport is configured, the dispatch functions log an informational message and return safely without throwing unhandled exceptions.
Sources: [packages/email/src/index.ts:1-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L1-L72)
### Dispatch Switching Logic
When `sendEmail` is invoked with `ResendEmailOptions`, it first evaluates whether the `resend` client instance is present. If active, it delegates immediately to `sendEmailViaResend`. When Resend is absent, it inspects `process.env.SMTP_HOST` and `process.env.SMTP_PORT` to determine if SMTP is configured. If `smtpConfigured` evaluates to true, it extracts `to`, `subject`, `text`, and `react` properties and dispatches via `sendViaNodeMailer`.
```mermaid
graph TD
A[sendEmail / sendBatchEmail] --> B{resend client initialized?}
B -- Yes --> C[Dispatch via Resend API]
B -- No --> D{SMTP_HOST & SMTP_PORT set?}
D -- Yes --> E[Dispatch via Nodemailer SMTP]
D -- No --> F[Log configuration warning & exit]
```
Sources: [packages/email/src/index.ts:6-29](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L6-L29), [packages/email/src/resend/client.ts:3-5](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts#L3-L5)
> [!NOTE]
> The batch dispatch wrapper `sendBatchEmail` accepts an array of bulk email options alongside an optional `idempotencyKey`. Under the SMTP fallback path, it executes individual mail transmissions concurrently using `Promise.all` and returns a generated structure containing random UUID identifiers mapped to each recipient.
Sources: [packages/email/src/index.ts:31-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/index.ts#L31-L72)
### Transport Mechanisms and Configuration
The underlying transport adapters initialize differently based on their target service. The Resend client instantiates using `process.env.RESEND_API_KEY`, while Nodemailer constructs an SMTP transporter with insecure TLS rejection overrides (`tls: { rejectUnauthorized: false }`) and renders React Email components into HTML strings using `@react-email/render` and `pretty`.
| Transport Module | Primary File | Core Function | Configuration Dependency |
| :--- | :--- | :--- | :--- |
| Resend Single | [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts) | `sendEmailViaResend` | `RESEND_API_KEY` |
| Resend Batch | [packages/email/src/send-via-resend.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts) | `sendBatchEmailViaResend` | `RESEND_API_KEY` |
| Nodemailer | [packages/email/src/send-via-nodemailer.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts) | `sendViaNodeMailer` | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD` |
| Client Init | [packages/email/src/resend/client.ts](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts) | `resend` export | `RESEND_API_KEY` |
Sources: [packages/email/src/send-via-resend.ts:92-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L92-L153), [packages/email/src/send-via-nodemailer.ts:6-35](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-nodemailer.ts#L6-L35), [packages/email/src/resend/client.ts:3-5](https://github.com/blade47/dub/blob/HEAD/packages/email/src/resend/client.ts#L3-L5)
## Recipient Filtering and Address Rewriting
### Recipient Filtering and Address Rewriting
### Blocked Recipient Detection and Sandbox Rewriting
Resend rejects emails sent to reserved test domains with a 422 status code. To prevent delivery failures during testing or previews, the email delivery pipeline intercepts outgoing addresses and evaluates them against a static list of reserved domains (`example.com`, `example.net`, `example.org`, and `test.com`). The helper function `isResendBlockedRecipient` normalizes the target email address by converting it to lowercase, trimming whitespace, extracting the domain portion after the `@` symbol, and testing whether it matches or subdomains any entry in `RESEND_BLOCKED_DOMAINS`.
Sources: [packages/email/src/send-via-resend.ts:8-22](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L22)
> [!WARNING]
> When `VERCEL_ENV` is set to `"preview"`, recipient addresses are unconditionally overridden with `"delivered@resend.dev"` regardless of the original target. In non-preview environments, addresses matching `RESEND_BLOCKED_DOMAINS` are rewritten by `rewriteBlockedRecipient` to `delivered+username@resend.dev` (where `username` is extracted from the local part of the original address) to preserve batch cardinality for zipping Resend IDs by index while routing through Resend's test sink.
Sources: [packages/email/src/send-via-resend.ts:24-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L24-L33), [packages/email/src/send-via-resend.ts:53-59](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L53-L59)
### Resend Delivery Options Compilation
The `resendEmailForOptions` function transforms incoming `ResendEmailOptions` into the standard `CreateEmailOptions` structure required by the Resend SDK. It orchestrates recipient rewriting, sender resolution via `VARIANT_TO_FROM_MAP`, conditional branch evaluation for reply-to fallbacks (`support@dub.co` or omission when set to `"noreply"`), and marketing list-unsubscribe header injections.
Sources: [packages/email/src/send-via-resend.ts:35-77](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L35-L77)
The compilation and dispatch call chain proceeds through specific internal layers before hitting the external SDK client:
`sendEmailViaResend()` or `sendBatchEmailViaResend()` → checks `resend` client initialization → `resendEmailForOptions()` → `isResendBlockedRecipient()` → `rewriteBlockedRecipient()` → `resend.emails.send()` or `resend.batch.send()`.
Sources: [packages/email/src/send-via-resend.ts:35-101](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L35-L101), [packages/email/src/send-via-resend.ts:127-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L127-L153)
| Constant / Helper | Value / Target | Purpose |
| :--- | :--- | :--- |
| `RESEND_BLOCKED_DOMAINS` | `example.com`, `example.net`, `example.org`, `test.com` | Identifies reserved domains that trigger Resend 422 errors |
| Preview Recipient | `delivered@resend.dev` | Fallback recipient used across all preview deployment environments |
| Blocked Rewrite Pattern | `delivered+{username}@resend.dev` | Preserves batch index cardinality for blocked test addresses |
| Default Reply-To | `support@dub.co` | Fallback address when `replyTo` is omitted (`noreply` omits the field entirely) |
| Marketing Unsubscribe | `https://app.dub.co/account/settings` | Default `List-Unsubscribe` header URL for marketing variants |
Sources: [packages/email/src/send-via-resend.ts:8-13](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L13), [packages/email/src/send-via-resend.ts:25-30](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L25-L30), [packages/email/src/send-via-resend.ts:59](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L59), [packages/email/src/send-via-resend.ts:65](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L65), [packages/email/src/send-via-resend.ts:72-73](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L72-L73)
### Batch Processing and Filtering
When `sendBatchEmailViaResend` executes, it verifies that the input array is non-empty and filters out any item lacking a `to` address via `Array.reduce`. Each valid email option object is mapped through `resendEmailForOptions` to produce a `filteredBatch`. If an `idempotencyKey` option is provided, it is forwarded directly to `resend.batch.send`.
Sources: [packages/email/src/send-via-resend.ts:103-153](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L103-L153)
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Static blocklist matching (`RESEND_BLOCKED_DOMAINS`) | Prevents upstream 422 API rejections with zero external roundtrips | Requires manual maintenance if Resend adds reserved domains |
| Cardinality-preserving address rewriting (`delivered+user@resend.dev`) | Maintains exact batch array lengths so callers can zip returned Resend IDs | Rewrites destination mailboxes to a test sink, preventing real receipt |
| Conditional body rendering (`react` vs `text` check) | Ensures `CreateEmailOptions` always receives at least one valid render payload | Forces fallback to an empty string `text: ""` if both are absent |
Sources: [packages/email/src/send-via-resend.ts:8-22](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L8-L22), [packages/email/src/send-via-resend.ts:24-33](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L24-L33), [packages/email/src/send-via-resend.ts:79-88](https://github.com/blade47/dub/blob/HEAD/packages/email/src/send-via-resend.ts#L79-L88)
## Asynchronous Batch Dispatch and QStash
### Overview
Bulk email delivery in Dub is handled asynchronously by chunking recipient lists and queueing them through QStash. The `queueBatchEmail()` function splits large arrays into deterministic chunks of 100 recipients (`BATCH_SIZE`) and pushes each chunk to the QStash `send-batch-email` queue. When idempotency keys are supplied, they are automatically suffixed per batch index (e.g., `${options.idempotencyKey}-batch-${i}`) to provide both QStash deduplication and Resend upstream deduplication.
Sources: [apps/web/lib/email/queue-batch-email.ts:12-60](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L12-L60)
### Template Mapping and Execution Walkthrough
When QStash delivers a batch payload to the cron endpoint (`POST /api/cron/send-batch-email`), the request flows through a rigid verification and execution sequence:
`POST` handler → `verifyQstashSignature()` → `batchEmailPayloadSchema.parse()` → `Promise.allSettled()` iteration → `EMAIL_TEMPLATES_MAP[emailItem.templateName]` lookup → `React.createElement()` rendering → `sendBatchEmail()` → `NextResponse.json()` confirmation.
Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:38-147](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L38-L147)
> [!WARNING]
> If any individual email item in a batch references an unknown template name that is absent from `EMAIL_TEMPLATES_MAP`, that specific item fails its promise settlement, logs a database error with `log()`, and appends an error object to the response while allowing valid items in the batch to proceed.
Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:56-121](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L56-L121)
### Registered Email Templates
The `EMAIL_TEMPLATES_MAP` object binds string identifiers to their respective React email components, supporting standard administrative notifications as well as broadcast announcements.
| Template Key | Source Component Path | Primary Purpose |
| :--- | :--- | :--- |
| `BountyApproved` | `@dub/email/templates/bounty-approved` | Partner bounty reward approval confirmation |
| `ConnectPayoutReminder` | `@dub/email/templates/connect-payout-reminder` | Stripe Connect payout setup reminder |
| `ConnectPlatformsReminder` | `@dub/email/templates/connect-platforms-reminder` | Platform integration onboarding reminder |
| `PartnerPayoutConfirmed` | `@dub/email/templates/partner-payout-confirmed` | Confirmation of confirmed partner payout |
| `PartnerPayoutProcessed` | `@dub/email/templates/partner-payout-processed` | Notification that partner payout has been sent |
| `PartnerDeactivated` | `@dub/email/templates/partner-deactivated` | Partner program account deactivation notice |
| `PartnerBanned` | `@dub/email/templates/partner-banned` | Notice of partner program ban |
| `ProgramPayoutThankYou` | `@dub/email/templates/program-payout-thank-you` | Gratitude notice following program payouts |
| `UnresolvedRiskEventsSummary` | `@dub/email/templates/unresolved-risk-events-summary` | Security and risk events digest summary |
| `PartnerGroupChanged` | `@dub/email/templates/partner-group-changed` | Partner commission tier or group change notice |
| `PartnerRewardUpdated` | `@dub/email/templates/partner-reward-updated` | Partner reward structure update alert |
| `WorkspaceDisabled` | `@dub/email/templates/workspace-disabled` | Workspace suspension notice |
| `DubStartupProgramAnnouncement` | `@dub/email/templates/broadcasts/dub-startup-program-announcement` | Special broadcast for startup program applicants |
| `DubProductUpdateSummer26` | `@dub/email/templates/broadcasts/dub-product-update-summer26` | Seasonal product update broadcast |
Sources: [apps/web/lib/email/email-templates-map.ts:1-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/email-templates-map.ts#L1-L32)
### Queue Architecture Design Trade-offs
| Design Choice | Benefit | Cost |
| :--- | :--- | :--- |
| Fixed chunk size (`BATCH_SIZE = 100`) | Prevents HTTP payload size violations and keeps memory bounded | Multi-batch jobs require sequential queue entries per chunk |
| Indexed idempotency key suffixes (`-batch-{i}`) | Enables precise retry deduplication across split chunks | Requires callers to pass unique root idempotency keys |
| Parallel template rendering (`Promise.allSettled`) | Speeds up batch compilation without failing the entire batch on one bad template | Consumes memory rendering all React components simultaneously per batch |
Sources: [apps/web/app/ee/api/cron/send-batch-email/route.ts:54-87](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/send-batch-email/route.ts#L54-L87), [apps/web/lib/email/queue-batch-email.ts:12-46](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/email/queue-batch-email.ts#L12-L46)
## React Email Template Design System
### Overview
The email package defines template layouts using React Email components combined with Tailwind CSS styling. Each template establishes an HTML structure wrapped in standard metadata containers, wordmark branding elements, and responsive layout wrappers.
Sources: [packages/email/src/templates/webhook-failed.tsx:43-48](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L43-L48), [packages/email/src/templates/verify-email.tsx:24-32](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx#L24-L32)
### Shared Footers and Layout Elements
Templates accept structured configuration props and render consistent branding components. The `DUB_WORDMARK` asset is consistently loaded across templates via `Img`, while the reusable `Footer` component handles recipient email display and profile notification links.
| Template Component | Primary Action / Link | Footer Configuration |
| :--- | :--- | :--- |
| `WebhookFailed` | Edit Webhook ([/settings/webhooks/[id]/edit](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L67)) | `email={email}` |
| `WebhookDisabled` | Edit Webhook ([/settings/webhooks/[id]/edit](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx#L65)) | `email={email}` |
| `WebhookAdded` | View Webhook ([/settings/webhooks](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx#L56)) | `email={email}` |
| `VerifyEmail` | Verification Code (`10 min` expiry) | `email={email}` |
| `PartnerTremendousVerifyEmail` | Gift Card Payout Verification (`expiryMinutes` prop) | `email={email}` |
| `FeedbackEmail` | Direct feedback text display | No footer component |
Sources: [packages/email/src/templates/webhook-failed.tsx:17-73](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-failed.tsx#L17-L73), [packages/email/src/templates/webhook-disabled.tsx:17-71](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-disabled.tsx#L17-L71), [packages/email/src/templates/webhook-added.tsx:17-72](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/webhook-added.tsx#L17-L72), [packages/email/src/templates/verify-email.tsx:16-48](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/verify-email.tsx#L16-L48), [packages/email/src/templates/partner-tremendous-verify-email.tsx:16-56](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/partner-tremendous-verify-email.tsx#L16-L56), [packages/email/src/templates/feedback-email.tsx:15-41](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/feedback-email.tsx#L15-L41)
### Lead and Notification Layouts
Complex notification templates render participant profiles, lead cards, and threaded messages. `NewMessageFromPartner` enforces a display cap using `MAX_DISPLAYED_MESSAGES = 3` and appends an overflow text indicator when message counts exceed this threshold.
```typescript
const MAX_DISPLAYED_MESSAGES = 3;
```
Sources: [packages/email/src/templates/new-message-from-partner.tsx:24-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L24-L24)
> [!NOTE]
> `NewMessageFromPartner` truncates rendered message rows at `MAX_DISPLAYED_MESSAGES` but calculates overflow totals using `messages.length - MAX_DISPLAYED_MESSAGES` to inform recipients of additional unrendered messages in the thread.
Sources: [packages/email/src/templates/new-message-from-partner.tsx:99-138](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L99-L138)
| Lead / Notification Template | Key Props Rendered | Interactive Links / Destinations |
| :--- | :--- | :--- |
| `LeadStatusUpdated` | `partner`, `program`, `lead`, `notes` | Notification settings profile URL |
| `NewSubmittedLeadCommentsFromProgram` | `program`, `lead`, `comments`, `email` | `partners.dub.co/programs/{slug}/leads?leadId={id}` |
| `NewSubmittedLeadCommentsFromPartner` | `workspace`, `partner`, `lead`, `comments`, `email` | `app.dub.co/{slug}/program/leads?leadId={id}` |
| `NewMessageFromPartner` | `workspaceSlug`, `partner`, `messages`, `email` | `app.dub.co/{slug}/program/messages/{partnerId}` |
Sources: [packages/email/src/templates/lead-status-updated.tsx:16-44](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/lead-status-updated.tsx#L16-L44), [packages/email/src/templates/new-submitted-lead-comments-from-program.tsx:22-61](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-program.tsx#L22-L61), [packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx:20-58](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-submitted-lead-comments-from-partner.tsx#L20-L58), [packages/email/src/templates/new-message-from-partner.tsx:26-69](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/new-message-from-partner.tsx#L26-L69)
## Custom Domains and Campaign Previews
### Overview
Custom email domains undergo periodic verification checks and permit campaign preview transmissions. When custom domains require configuration, instructions can be emailed directly to target recipients or managed via domain status change handlers. Campaign previews render test communications using default template variables and enforce domain ownership rules.
Sources: [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts:1-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L1-L134), [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:1-98](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L1-L98), [apps/web/app/ee/api/cron/email-domains/verify/route.ts:1-134](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L1-L134)
### DNS Instruction Forwarding and Rate Limiting
The DNS forwarding endpoint (`POST /api/email-domains/[domain]/forward-instructions`) retrieves Resend domain records, maps them to forward rows, and dispatches them via email. The route applies strict rate limiting policies before interacting with Resend or sending messages.
```typescript
// Rate limit policies applied in forward-instructions
await assertRateLimit({
policy: RATELIMIT_POLICIES.forwardDnsInstructions,
identifier: [workspace.id, session.user.id],
});
await assertRateLimit({
policy: RATELIMIT_POLICIES.forwardDnsInstructionsTarget,
identifier: email.toLowerCase(),
});
```
Sources: [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:31-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L31-L39)
> [!WARNING]
> If `resendDomainId` is missing from the email domain or Resend returns zero records, the forward-instructions route throws a `bad_request` or `internal_server_error` DubApiError, halting email delivery.
Sources: [apps/web/app/ee/api/email-domains/domain/forward-instructions/route.ts:48-71](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/email-domains/%5Bdomain%5D/forward-instructions/route.ts#L48-L71)
### Domain Verification Cron and Status Mapping
The email domain verification cron (`GET /api/cron/email-domains/verify`) executes hourly (`0 * * * *`), querying up to 10 email domains ordered by `lastChecked: asc` that possess a `resendDomainId`.
| Email Domain Status | Notification Subject Line |
| :--- | :--- |
| `verified` | `Your email domain has been verified` |
| `failed` | `Your email domain verification has failed` |
| `partially_failed` | `Your email domain verification has failed` |
Sources: [apps/web/app/ee/api/cron/email-domains/verify/route.ts:12-36](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L12-L36), [packages/email/src/templates/email-domain-status-changed.tsx:17-24](https://github.com/blade47/dub/blob/HEAD/packages/email/src/templates/email-domain-status-changed.tsx#L17-L24)
> [!NOTE]
> Only workspace owners with the `domainConfigurationUpdates` notification preference receive status change emails when verification transitions occur.
Sources: [apps/web/app/ee/api/cron/email-domains/verify/route.ts:92-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/cron/email-domains/verify/route.ts#L92-L96)
### Campaign Preview Rendering and Validation
The campaign preview endpoint (`POST /api/campaigns/[campaignId]/preview`) validates that test recipient counts remain between 1 and 10 addresses. If a custom `from` address is specified, it parses the address and verifies that the domain matches a verified email domain linked to the workspace program.
```typescript
const sendPreviewEmailSchema = CampaignSchema.pick({
subject: true,
preview: true,
bodyJson: true,
}).extend({
from: campaignFromSchema.optional(),
emailAddresses: z
.array(z.email())
.min(1)
.max(10, "Maximum 10 email addresses allowed."),
});
```
Sources: [apps/web/app/ee/api/campaigns/campaignId/preview/route.ts:24-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/campaigns/%5BcampaignId%5D/preview/route.ts#L24-L34)
## Resend Webhook Ingestion and Failures
### Overview
The Resend Webhook ingestion pipeline processes external delivery events originating from Resend, while companion workers handle consecutive delivery failures and workspace owner notifications. Incoming webhooks are received at `POST /api/resend/webhook`, where payload integrity is verified using Svix headers before routing events to specific handlers based on the event type (`email.opened`, `email.delivered`, or `email.bounced`).
Sources: [apps/web/app/api/resend/webhook/route.ts:10-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L10-L34)
### Webhook Verification and Event Routing Walkthrough
Incoming requests to the Resend webhook endpoint execute a multi-step verification and dispatch procedure:
1. `req.text()` — Extracts the raw request body as a string.
2. `Webhook.verify()` — Validates the Svix signature using `svix-id`, `svix-timestamp`, and `svix-signature` request headers against `process.env.RESEND_WEBHOOK_SECRET`, throwing an error on failure.
3. `JSON.parse()` — Parses the verified raw body to extract the `type` and `data` properties.
4. `switch (type)` — Dispatches the event payload to its corresponding handler function.
```typescript
const rawBody = await req.text();
const webhook = new Webhook(webhookSecret);
webhook.verify(rawBody, {
"svix-id": req.headers.get("svix-id")!,
"svix-timestamp": req.headers.get("svix-timestamp")!,
"svix-signature": req.headers.get("svix-signature")!,
});
const { type, data } = JSON.parse(rawBody) || {};
switch (type) {
case "email.opened":
await emailOpened(data);
break;
case "email.delivered":
await emailDelivered(data);
break;
case "email.bounced":
await emailBounced(data);
break;
}
```
Sources: [apps/web/app/api/resend/webhook/route.ts:12-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L12-L34)
> [!CAUTION]
> Omitting any of the three Svix validation headers (`svix-id`, `svix-timestamp`, or `svix-signature`) causes `webhook.verify()` to throw an immediate validation error, aborting webhook processing and returning a 500-level response.
Sources: [apps/web/app/api/resend/webhook/route.ts:16-20](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/resend/webhook/route.ts#L16-L20)
### Webhook Failure Handling and Notification Thresholds
When external webhook endpoints fail to receive dispatches, the failure management utility (`handleWebhookFailure`) increments the `consecutiveFailures` counter and updates `lastFailedAt` on the webhook record.
Sources: [apps/web/lib/webhook/failure.ts:12-31](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L12-L31)
| Condition / Threshold | Trigger Action | Executed Functions |
| :--- | :--- | :--- |
| `webhook.disabledAt` present | None (skips execution) | Early return |
| `WEBHOOK_FAILURE_NOTIFY_THRESHOLDS` matched | Sends failure notification email to workspace owners | `notifyWebhookFailure()` |
| `consecutiveFailures >= WEBHOOK_FAILURE_DISABLE_THRESHOLD` | Disables webhook, alerts owners, and syncs status | `notifyWebhookDisabled()`, `syncWorkspaceWebhookStatus()` |
Sources: [apps/web/lib/webhook/failure.ts:33-62](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L33-L62)
> [!TIP]
> Webhook failure counters can be completely reset by invoking `resetWebhookFailureCount(webhookId)`, which sets `consecutiveFailures` back to `0` and clears `lastFailedAt`.
Sources: [apps/web/lib/webhook/failure.ts:65-73](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/failure.ts#L65-L73)
## Related
- [[Campaign Broadcaster]]
- [[Partner Portal and Onboarding]]
---
## Technical docs: POST Reset login attempts
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/resetloginattempts
## Request Body
User email
## Responses
## Try It
---
## Technical docs: GET Get admin revenue timeseries
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/getadminrevenue
## Parameters
## Responses
## Try It
---
## Technical docs: Webhooks and Postbacks
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/automation-and-communications/webhooks-and-postbacks
Relevant source files
The following files were used as context for generating this wiki page:
- [apps/web/lib/webhook/schemas.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/schemas.ts)
- [apps/web/lib/postback/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/constants.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/singular/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts)
- [apps/web/app/ee/api/stripe/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts)
- [apps/web/lib/postback/schemas.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/schemas.ts)
- [apps/web/app/ee/api/stripe/integration/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/lib/webhook/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/constants.ts)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/ee/api/partner-profile/postbacks/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts)
- [apps/web/app/api/webhooks/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts)
- [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/%5BpostbackId%5D/page.tsx)
- [apps/web/app/ee/api/stripe/connect/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts)
- [apps/web/app/api/webhooks/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts)
- [apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts)
- [apps/web/scripts/dev/data.json](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/data.json)
- [apps/web/app/ee/api/intercom/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts)
- [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/page.tsx)
- [apps/web/lib/analytics/types.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/types.ts)
- [apps/web/app/ee/api/paypal/webhook/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/paypal/webhook/route.ts)
- [apps/web/app/api/postbacks/callback/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/postbacks/callback/route.ts)
- [apps/web/lib/zod/schemas/analytics-response.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/zod/schemas/analytics-response.ts)
- [apps/web/lib/postback/postback-adapters.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts)
- [apps/web/lib/analytics/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/analytics/constants.ts)
- [apps/web/ui/partners/format-reward-description.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/format-reward-description.ts)
- [apps/web/app/api/dub/webhook/sale-created.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/sale-created.ts)
- [apps/web/lib/partner-referrals/constants.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/partner-referrals/constants.ts)
- [apps/web/ui/partners/program-reward-description.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/partners/program-reward-description.tsx)
- [apps/web/scripts/dev/simulate-shopify-conversion.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dev/simulate-shopify-conversion.ts)
## Overview
Webhooks and postbacks facilitate real-time event-driven integrations by securely transmitting data between Dub workspaces, external partners, and third-party services. The system handles both outbound webhook dispatching through queue pipelines and inbound webhook ingestion from external payment gateways, attribution networks, and partner applications, ensuring reliable event delivery, signature verification, and delivery failure tracking.
Sources: [apps/web/app/api/webhooks/route.ts:34-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts#L34-L105), [apps/web/app/api/webhooks/callback/route.ts:22-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts#L22-L104), [apps/web/app/ee/api/partner-profile/postbacks/route.ts:41-86](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts#L41-L86)
## Webhook Configuration and Lifecycle Management
### Overview
Workspace webhooks are provisioned and managed through public API routes residing at `/api/webhooks`, enforcing workspace-level permissions and plan requirements. The management lifecycle spans retrieving existing webhook configurations via `GET` and provisioning new endpoints via `POST`. Every webhook operation validates incoming payloads using Zod schemas, checks required permissions such as `webhooks.read` and `webhooks.write`, and restricts access to specific subscription plans including `business`, `advanced`, and `enterprise`.
Sources: [apps/web/app/api/webhooks/route.ts:18-32](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts#L18-L32), [apps/web/app/api/webhooks/route.ts:35-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts#L35-L105)
### Provisioning Call-Chain Execution Walkthrough
The creation of a new workspace webhook follows an explicit execution flow through the public API route handler. When a `POST` request is received, the operation proceeds through the following call chain:
`withWorkspace()` → `parseRequestBody()` → `createWebhookSchema.parse()` → `validateWebhook()` → `identifyWebhookReceiver()` → `prisma.installedIntegration.findFirst()` → `createWebhook()` → `sendEmail()` → `NextResponse.json()`
1. **`withWorkspace()`** resolves and validates the workspace from the session context, enforcing that the caller possesses the `webhooks.write` permission and belongs to a `business`, `advanced`, or `enterprise` subscription plan.
2. **`parseRequestBody()`** and **`createWebhookSchema.parse()`** extract and validate the raw request body against the Zod creation schema.
3. **`validateWebhook()`** executes business rules and input checks against the workspace and user session.
4. **`identifyWebhookReceiver()`** inspects the target URL to determine if the destination is an integrated receiver such as Zapier (`WebhookReceiver.zapier`).
5. **`prisma.installedIntegration.findFirst()`** queries database records for installed integrations when Zapier is identified, matching the workspace ID and `ZAPIER_INTEGRATION_ID`.
6. **`createWebhook()`** provisions the webhook entity in the database with the provided name, URL, triggers, link scope, link IDs, folder IDs, and installation ID.
7. **`sendEmail()`** executes asynchronously via Vercel's `waitUntil` utility, dispatching a confirmation notification using the `WebhookAdded` email template.
8. **`NextResponse.json()`** serializes the created webhook using `WebhookSchema.parse()` and returns HTTP status `201`.
Sources: [apps/web/app/api/webhooks/route.ts:35-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/route.ts#L35-L100)
### Webhook Constants and Failure Thresholds
The webhook subsystem defines strict operational thresholds and identifier prefixes to govern endpoint reliability and automatic disabling.
| Constant Name | Value | Description |
| :--- | :--- | :--- |
| `WEBHOOK_SECRET_LENGTH` | `16` | Length of the generated webhook signing secret. |
| `WEBHOOK_ID_PREFIX` | `wh_` | Prefix string for webhook entity identifiers. |
| `WEBHOOK_SECRET_PREFIX` | `whsec_` | Prefix string for webhook signing secrets. |
| `WEBHOOK_EVENT_ID_PREFIX` | `evt_` | Prefix string for dispatched webhook event identifiers. |
| `WEBHOOK_FAILURE_NOTIFY_THRESHOLDS` | `[5, 10, 15]` | Consecutive failure counts that trigger notification alerts. |
| `WEBHOOK_FAILURE_DISABLE_THRESHOLD` | `20` | Consecutive failure count that automatically disables the webhook. |
| `MAX_WEBHOOK_FOLDERS` | `100` | Maximum number of folders allowed for scoping webhooks. |
Sources: [apps/web/lib/webhook/constants.ts:3-65](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/constants.ts#L3-L65)
> [!WARNING]
> Reaching the `WEBHOOK_FAILURE_DISABLE_THRESHOLD` of 20 consecutive delivery failures will automatically disable the webhook endpoint. Operators must monitor failure notification thresholds at 5, 10, and 15 failures to prevent automatic disabling of critical integrations.
Sources: [apps/web/lib/webhook/constants.ts:62-63](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/constants.ts#L62-L63)
### Workspace and Program Event Triggers
Webhook triggers are divided into workspace-level and program-level event categories. Workspace triggers govern core link and conversion actions, while program-level triggers manage partner ecosystems, bounties, payouts, and discount codes.
| Trigger Category | Event Identifiers |
| :--- | :--- |
| `WORKSPACE_LEVEL_WEBHOOK_TRIGGERS` | `link.created`, `link.updated`, `link.deleted`, `link.clicked`, `lead.created`, `sale.created` |
| `PROGRAM_LEVEL_WEBHOOK_TRIGGERS` | `partner.application_submitted`, `partner.enrolled`, `partner.merged`, `commission.created`, `bounty.created`, `bounty.updated`, `payout.confirmed`, `discount_code.created`, `discount_code.deleted` |
Sources: [apps/web/lib/webhook/constants.ts:13-32](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/constants.ts#L13-L32)
## Outbound Delivery Pipeline and Callback Tracking
### Overview
The outbound delivery pipeline dispatches events via QStash, generating cryptographic signatures and handling status tracking through callback endpoints. The `PostbackAdapter` class handles event transformation, search parameter construction, signature generation, and QStash publishing.
Sources: [apps/web/lib/postback/postback-adapters.ts:15-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts#L15-L68)
### Postback Execution Call Chain
The postback dispatch and callback tracking pipeline proceeds through the following call chain:
`PostbackAdapter.execute()` → `this.eventTransformers.transform()` → `buildCallbackUrl()` → `createWebhookSignature()` → `qstash.publishJSON()` → `POST()` → `verifyQstashSignature()` → `webhookCallbackSchema.parse()` → `prisma.webhook.findUnique()` → `Promise.allSettled()` → `recordWebhookEvent()`
1. **`PostbackAdapter.execute()`** receives a `PostbackPayload` containing the event identifier, trigger type, creation timestamp, and raw data.
2. **`this.eventTransformers.transform()`** maps the payload to the specific adapter format, returning immediately if transformation yields no result.
3. **`buildCallbackUrl()`** constructs the destination URL for QStash status tracking, appending query parameters for `postbackId`, `eventId`, and `event`.
4. **`createWebhookSignature()`** computes a cryptographic signature using the postback secret and the transformed payload.
5. **`qstash.publishJSON()`** dispatches the HTTP request to the target URL via QStash, configuring both `callback` and `failureCallback` properties to point to the generated callback URL with headers `"Dub-Signature"` and `"Upstash-Hide-Headers": "true"`.
6. **`POST()`** in `/api/webhooks/callback` acts as the webhook status listener for QStash callbacks.
7. **`verifyQstashSignature()`** validates the incoming QStash signature against the raw request body.
8. **`webhookCallbackSchema.parse()`** and `searchParamsSchema.parse()` extract and validate callback body attributes (`url`, `status`, `body`, `sourceBody`, `sourceMessageId`) and query parameters (`webhookId`, `eventId`, `event`, `failed`).
9. **`prisma.webhook.findUnique()`** queries the database to locate the associated webhook entity by its identifier.
10. **`Promise.allSettled()`** executes concurrent post-delivery operations including event recording, failure handling, and payout processing.
11. **`recordWebhookEvent()`** logs the event details to Tinybird with the request body, response body, HTTP status, and message ID.
Sources: [apps/web/app/api/webhooks/callback/route.ts:22-104](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts#L22-L104), [apps/web/lib/postback/postback-adapters.ts:24-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts#L24-L68)
### Callback Processing and Failure Handling
The callback handler evaluates delivery status codes to manage consecutive failures, automated cleanups, and external payout event statuses.
| Condition / Status | Action Taken |
| :--- | :--- |
| `status === 410` and receiver is `zapier` with installation ID | Deletes the webhook entity via `prisma.webhook.delete()` and uninstalls the Zapier webhook. |
| `status >= 400` or `status === -1` | Treats delivery as failed (`isFailed = true`), invokes `handleWebhookFailure(webhookId)`, and maps status `-1` to HTTP `503` for logging. |
| `webhook.consecutiveFailures > 0` and `!isFailed` | Resets the webhook failure count via `resetWebhookFailureCount(webhookId)`. |
| `event === "payout.confirmed"` | Invokes `handleExternalPayoutEvent()` with payload status mapped to `"failure"` (if delivery failed), `"temporary_failure"` (if `isFailed`), or `"success"`. |
Sources: [apps/web/app/api/webhooks/callback/route.ts:49-100](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts#L49-L100)
> [!NOTE]
> QStash status callbacks arriving with an HTTP status of `-1` are normalized to HTTP status `503` before being recorded in Tinybird via `recordWebhookEvent`.
Sources: [apps/web/app/api/webhooks/callback/route.ts:72](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts#L72)
> [!WARNING]
> Zapier webhook endpoints returning a `410` Gone status trigger automatic deletion of the webhook record from the database to clean up stale integrations.
Sources: [apps/web/app/api/webhooks/callback/route.ts:52-64](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/webhooks/callback/route.ts#L52-L64)
## Partner Postback Integration and Adapters
### Overview
Partner postbacks allow affiliates and partners to receive real-time HTTP notifications for conversion events. The system governs postback provisioning through partner profile API endpoints that enforce validation constraints, channel receiver identification, and payload adapters.
Sources: [apps/web/app/ee/api/partner-profile/postbacks/route.ts:21-86](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts#L21-L86), [apps/web/lib/postback/postback-adapters.ts:8-69](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts#L8-L69)
### Creation and Execution Call Chain
Dispatching partner postback events proceeds through a specific execution order involving abstract adapter execution, event transformation, URL construction, signature creation, and QStash publishing:
`PostbackAdapter.execute()` → `this.eventTransformers.transform()` → `buildCallbackUrl()` → `createWebhookSignature()` → `qstash.publishJSON()`
1. **`PostbackAdapter.execute()`** accepts a `PostbackPayload` containing the event identifier, trigger type, timestamp, and raw data.
2. **`this.eventTransformers.transform()`** transforms the payload format, returning early if no valid transformation is produced.
3. **`buildCallbackUrl()`** constructs the destination tracking URL by appending `postbackId`, `eventId`, and `event` query parameters to the base callback endpoint.
4. **`createWebhookSignature()`** computes a cryptographic signature utilizing the postback secret and transformed payload.
5. **`qstash.publishJSON()`** sends the request to the target URL via QStash, passing headers `"Dub-Signature"` and `"Upstash-Hide-Headers": "true"`.
Sources: [apps/web/lib/postback/postback-adapters.ts:24-68](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/postback-adapters.ts#L24-L68)
### Configuration and Schema Constants
Postback validation rules, supported event triggers, and database schemas are strictly defined to govern incoming requests and partner profile creation payloads.
| Constant / Schema | Value / Structure | Purpose |
| :--- | :--- | :--- |
| `POSTBACK_SECRET_LENGTH` | `16` | Character length for generated postback secrets. |
| `POSTBACK_SECRET_PREFIX` | `"pbsec_"` | String prefix prepended to all generated postback secrets. |
| `POSTBACK_EVENT_ID_PREFIX` | `"evt_"` | String prefix prepended to postback event identifiers. |
| `MAX_POSTBACKS` | `5` | Maximum number of active postbacks permitted per partner profile. |
| `POSTBACK_TRIGGERS` | `["lead.created", "sale.created", "commission.created"]` | Supported event triggers for affiliate postbacks. |
Sources: [apps/web/lib/postback/constants.ts:1-20](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/constants.ts#L1-L20), [apps/web/lib/postback/schemas.ts:10-124](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/schemas.ts#L10-L124)
> [!IMPORTANT]
> The target URL provided during partner postback creation is strictly validated via `parseUrlSchema` to require the HTTPS protocol.
Sources: [apps/web/lib/postback/schemas.ts:26-28](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/schemas.ts#L26-L28)
> [!WARNING]
> Partner profile endpoints enforce a hard cap of `MAX_POSTBACKS` (5) per partner; attempting to create additional postbacks throws an `exceeded_limit` API error.
Sources: [apps/web/app/ee/api/partner-profile/postbacks/route.ts:48-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/partner-profile/postbacks/route.ts#L48-L59)
## Internal Dub Event Dispatch Receivers
### Overview
The internal webhook intake API routes incoming Dub internal webhook events, validates cryptographic signatures, and dispatches payloads to specific conversion handlers. The receiver endpoint processes core lifecycle events including link clicks, lead captures, and sale conversions.
Sources: [apps/web/app/api/dub/webhook/route.ts:8-40](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts#L8-L40), [apps/web/lib/webhook/schemas.ts:64-208](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/schemas.ts#L64-L208)
### Execution Call Chain and Request Verification
Incoming webhook requests flow through a strictly ordered intake and verification sequence before reaching event handlers:
`POST /api/dub/webhook` → `req.json()` → `webhookPayloadSchema.parse()` → `req.headers.get("Dub-Signature")` → `crypto.createHmac()` → `timingSafeCompare()` → `leadCreated()` / `saleCreated()`
1. **`POST /api/dub/webhook`** receives the raw HTTP request and parses the body via `req.json()`.
2. **`webhookPayloadSchema.parse(body)`** validates the structure against the base payload schema, extracting the `event` type and payload `data`.
3. **`req.headers.get("Dub-Signature")`** extracts the signature header, returning a `401` status response if no signature is provided.
4. **`crypto.createHmac()`** computes an expected SHA-256 HMAC digest of the stringified request body utilizing the secret stored in `process.env.DUB_WEBHOOK_SECRET`.
5. **`timingSafeCompare()`** compares the incoming header signature with the computed signature, returning a `400` status response if verification fails.
6. **`leadCreated()`** or **`saleCreated()`** executes based on the matched `event` switch case, returning an `"OK"` or status message response.
Sources: [apps/web/app/api/dub/webhook/route.ts:9-39](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/route.ts#L9-L39)
### Webhook Event Schemas and Handlers
The webhook payload schemas define strict Zod validation structures for inbound event types and associated metadata objects.
| Schema Constant | Base Validation Type | Description / Purpose |
| :--- | :--- | :--- |
| `webhookPayloadSchema` | `z.object` | Validates root webhook shape (`id`, `event`, `createdAt`, `data`). |
| `clickWebhookEventSchema` | `z.object` | Bundles `click` (`clickEventSchema`) and `link` (`linkEventSchema`) objects. |
| `leadWebhookEventSchema` | `z.object` | Validates `eventName`, `customer`, `click`, `link`, optional `partner`, and `metadata`. |
| `saleWebhookEventSchema` | `z.object` | Validates lead properties alongside `sale` (`amount`, `currency`, `paymentProcessor`, `invoiceId`). |
Sources: [apps/web/lib/webhook/schemas.ts:15-61](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/schemas.ts#L15-L61)
> [!WARNING]
> If a referral link is missing from the `sale.created` payload data during handler execution, `saleCreated()` immediately terminates execution and returns a `"Referral link not found in webhook payload"` string response rather than throwing an unhandled exception.
Sources: [apps/web/app/api/dub/webhook/sale-created.ts:3-8](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/dub/webhook/sale-created.ts#L3-L8)
> [!TIP]
> The `metadataSchema` field helper automatically runs `coerceJsonString` preprocessing on incoming payloads, attempting to parse JSON strings back into record structures while gracefully falling back to raw values if parsing fails.
Sources: [apps/web/lib/webhook/schemas.ts:27-42](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/webhook/schemas.ts#L27-L42)
## Inbound Partner and Provider Webhooks
### Overview
The inbound partner and provider webhooks subsystem handles external webhook ingestion from payment gateways, customer support tools, and mobile attribution networks. These API routes validate request signatures, verify origin IP ranges, filter out unsupported event types, and fan out or delegate payloads to specialized processors or conversion tracking utilities.
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:27-177](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L27-L177), [apps/web/app/ee/api/singular/webhook/route.ts:38-113](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L38-L113), [apps/web/app/ee/api/stripe/webhook/route.ts:33-105](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L33-L105), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:36-228](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L36-L228), [apps/web/app/ee/api/hubspot/webhook/route.ts:12-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L12-L73), [apps/web/app/ee/api/stripe/connect/webhook/route.ts:24-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts#L24-L91), [apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts:24-96](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts#L24-L96), [apps/web/app/ee/api/intercom/webhook/route.ts:12-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L12-L45), [apps/web/app/ee/api/paypal/webhook/route.ts:21-73](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/paypal/webhook/route.ts#L21-L73)
### Execution Call Chain and Security Validation
Incoming external webhook requests undergo strict validation routines before any business logic executes. Depending on the provider, validation involves cryptographic signature verification, constant-time hash comparisons, or IP range allowlisting:
`req.text()` / `getSearchParams()` → `req.headers.get("signature")` / `getIP()` → `stripe.webhooks.constructEvent()` / `timingSafeCompare()` / `isIpInRange()` → `prisma.project.findUnique()` → Event handler execution
1. **`req.text()` or `getSearchParams()`** reads the raw body payload or URL search parameters depending on whether the provider delivers JSON postbacks or query parameters.
2. **`req.headers.get()` or `getIP()`** extracts authentication signatures (e.g., `"Stripe-Signature"`, `"X-HubSpot-Signature"`) or resolves the client IP address for network-restricted endpoints.
3. **`stripe.webhooks.constructEvent()`**, **`timingSafeCompare()`**, or **`isIpInRange()`** validates the request integrity by checking HMAC signatures against secrets (e.g., `STRIPE_WEBHOOK_SECRET`, `HUBSPOT_CLIENT_SECRET`) or evaluating allowed IPCIDR ranges (`APPSFLYER_IP_RANGES`, `SINGULAR_IP_RANGES`).
4. **`prisma.project.findUnique()`** or workspace lookups locate the corresponding project workspace linked to the external identifier or Stripe Connect ID (`event.account`).
5. **Event handler execution** routes the parsed event through a `switch` statement or enqueues batch jobs via QStash for asynchronous processing.
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:37-89](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L37-L89), [apps/web/app/ee/api/singular/webhook/route.ts:40-91](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L40-L91), [apps/web/app/ee/api/stripe/webhook/route.ts:39-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L39-L48), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:57-70](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L57-L70), [apps/web/app/ee/api/hubspot/webhook/route.ts:15-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L15-L45), [apps/web/app/ee/api/stripe/connect/webhook/route.ts:28-52](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts#L28-L52), [apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts:28-55](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts#L28-L55), [apps/web/app/ee/api/intercom/webhook/route.ts:14](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L14), [apps/web/app/ee/api/paypal/webhook/route.ts:26-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/paypal/webhook/route.ts#L26-L33)
### Supported Inbound Webhook Routes and Event Sets
Each integration endpoint recognizes a distinct set of event types or topic identifiers. Unrecognized events are safely skipped and acknowledged to prevent repeated delivery retries from providers.
| Route Path | Validation Mechanism | Supported Events / Topics |
| :--- | :--- | :--- |
| `/api/stripe/webhook` | `Stripe-Signature` header & SDK construction | `charge.succeeded`, `charge.failed`, `charge.refunded`, `charge.dispute.created`, `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `payment_intent.requires_action`, `transfer.reversed` |
| `/api/stripe/integration/webhook` | `Stripe-Signature` header & mode-specific secrets | `account.application.deauthorized`, `charge.refunded`, `checkout.session.completed`, `coupon.deleted`, `customer.created`, `customer.updated`, `customer.subscription.created`, `customer.subscription.deleted`, `invoice.paid`, `promotion_code.updated` |
| `/api/stripe/connect/webhook` | `Stripe-Signature` header & `STRIPE_CONNECT_WEBHOOK_SECRET` | `account.application.deauthorized`, `account.external_account.updated`, `account.updated`, `balance.available`, `payout.paid`, `payout.failed` |
| `/api/stripe/connect/v2/webhook` | `Stripe-Signature` header & `STRIPE_CONNECT_V2_WEBHOOK_SECRET` | `v2.core.account.closed`, `v2.core.account[configuration.recipient].updated`, `v2.core.account[configuration.recipient].capability_status_updated`, `v2.money_management.outbound_payment.posted`, `v2.money_management.outbound_payment.returned`, `v2.money_management.outbound_payment.failed` |
| `/api/appsflyer/webhook` | IP range check (`APPSFLYER_IP_RANGES`) & query schema parsing | `lead`, `sale` (via `partnerEventId` query parameter) |
| `/api/singular/webhook` | IP range check (`SINGULAR_IP_RANGES`) & `dub_workspace_id` validation | `activated`, `sng_complete_registration`, `sng_subscribe`, `sng_ecommerce_purchase`, `__iap__`, `Copy GAID`, `copy IDFA` |
| `/api/hubspot/webhook` | `X-HubSpot-Signature` header & SHA-256 HMAC comparison | Array of webhook event objects (fanned out via QStash) |
| `/api/intercom/webhook` | HMAC signature verification (`verifyIntercomWebhookSignature`) | `conversation.admin.replied`, `ping` |
| `/api/paypal/webhook` | Signature verification (`verifySignature`) | `PAYMENT.PAYOUTS-ITEM.SUCCEEDED`, `PAYMENT.PAYOUTS-ITEM.BLOCKED`, `PAYMENT.PAYOUTS-ITEM.CANCELED`, `PAYMENT.PAYOUTS-ITEM.DENIED`, `PAYMENT.PAYOUTS-ITEM.FAILED`, `PAYMENT.PAYOUTS-ITEM.HELD`, `PAYMENT.PAYOUTS-ITEM.REFUNDED`, `PAYMENT.PAYOUTS-ITEM.RETURNED`, `PAYMENT.PAYOUTS-ITEM.UNCLAIMED` |
Sources: [apps/web/app/ee/api/appsflyer/webhook/route.ts:20-59](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/appsflyer/webhook/route.ts#L20-L59), [apps/web/app/ee/api/singular/webhook/route.ts:15-35](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/singular/webhook/route.ts#L15-L35), [apps/web/app/ee/api/stripe/webhook/route.ts:18-30](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/webhook/route.ts#L18-L30), [apps/web/app/ee/api/stripe/integration/webhook/route.ts:22-33](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L22-L33), [apps/web/app/ee/api/hubspot/webhook/route.ts:9-48](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L9-L48), [apps/web/app/ee/api/stripe/connect/webhook/route.ts:12-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/webhook/route.ts#L12-L19), [apps/web/app/ee/api/stripe/connect/v2/webhook/route.ts:12-19](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts#L12-L19), [apps/web/app/ee/api/intercom/webhook/route.ts:9-17](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L9-L17), [apps/web/app/ee/api/paypal/webhook/route.ts:7-18](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/paypal/webhook/route.ts#L7-L18)
> [!WARNING]
> When handling Stripe rate-limit errors (`isStripeRateLimitError`) within the Stripe integration webhook router, the endpoint returns HTTP status `429` instead of `500`. This instructs Stripe to automatically back off and retry the webhook delivery rather than treating the failure as an internal server error.
Sources: [apps/web/app/ee/api/stripe/integration/webhook/route.ts:193-204](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/stripe/integration/webhook/route.ts#L193-L204)
> [!TIP]
> High-volume webhook receivers like HubSpot and Intercom parse incoming batches and immediately fan out individual events to QStash queues (e.g., `process-hubspot-webhook`, `process-intercom-webhook`). This pattern keeps the primary HTTP ingestion handler fast and prevents a slow or failing payload from blocking the rest of the batch.
Sources: [apps/web/app/ee/api/hubspot/webhook/route.ts:54-62](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/hubspot/webhook/route.ts#L54-L62), [apps/web/app/ee/api/intercom/webhook/route.ts:27-34](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/api/intercom/webhook/route.ts#L27-L34)
## Partner Portal Postback Administration
### Overview
Partner Portal Postback Administration provides the user interface views and client-side orchestration layers for partners to register, manage, and inspect HTTP postbacks within the Dub partner ecosystem (`partners.dub.co`). Partners use these views to configure destination endpoints, manage signing secrets, and inspect real-time delivery event logs when conversion events occur.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/page.tsx:1-97](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/page.tsx#L1-L97), [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:1-158](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/%5BpostbackId%5D/page.tsx#L1-L158)
### Constants and Configuration
Postback configuration relies on specific structural and naming constants defined in the core postback library, governing secret formats, event ID prefixes, and trigger event options.
| Constant Name | Value / Format | Description |
| --- | --- | --- |
| `POSTBACK_SECRET_LENGTH` | `16` | The length of generated cryptographic postback secrets |
| `POSTBACK_SECRET_PREFIX` | `"pbsec_"` | Prefix string appended to postback signing secrets |
| `POSTBACK_EVENT_ID_PREFIX` | `"evt_"` | Prefix string appended to logged postback event IDs |
| `MAX_POSTBACKS` | `5` | Maximum number of active postbacks permitted per partner profile |
| `POSTBACK_TRIGGERS` | `lead.created`, `sale.created`, `commission.created` | Array of valid event trigger types supported by postbacks |
Sources: [apps/web/lib/postback/constants.ts:1-19](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/postback/constants.ts#L1-L19)
### Component Views and State Lifecycle
#### Postback List View (`/profile/postbacks`)
The main postback listing page queries `/api/partner-profile/postbacks` with SWR (`keepPreviousData: true`), rendering loading placeholders via `PostbackPlaceholder`, existing postback cards (`PostbackCard`), or an `EmptyState` component when no postbacks are configured. The view exposes an add action button linked to `openAddPostbackModal`.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/page.tsx:18-97](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/page.tsx#L18-L97)
#### Postback Detail View (`/profile/postbacks/[postbackId]`)
The detail view inspects a specific postback by fetching `/api/partner-profile/postbacks/{postbackId}` and its associated delivery events from `/api/partner-profile/postbacks/{postbackId}/events`.
```typescript
const {
data: postback,
error,
isLoading,
mutate,
} = useSWR(
postbackId ? `/api/partner-profile/postbacks/${postbackId}` : null,
fetcher,
);
const {
data: events,
error: eventsError,
isLoading: isEventsLoading,
} = useSWR(
postbackId ? `/api/partner-profile/postbacks/${postbackId}/events` : null,
fetcher,
{ keepPreviousData: true },
);
```
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:27-45](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/%5BpostbackId%5D/page.tsx#L27-L45)
If a 404 error is returned by the postback API endpoint (`error.status === 404`), the page automatically redirects the user back to the main `/profile/postbacks` route.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:56-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/%5BpostbackId%5D/page.tsx#L56-L58)
> [!WARNING]
> When a postback resource is deleted or returns a `404 Not Found` status, client-side error handling intercepts the status code and executes an immediate navigation redirect to `/profile/postbacks`, preventing stale UI states from persisting in the partner dashboard.
Sources: [apps/web/app/ee/partners.dub.co/dashboard/profile/postbacks/postbackId/page.tsx:56-58](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/postbacks/%5BpostbackId%5D/page.tsx#L56-L58)
## Related
- [[Conversion and Event Tracking]]
- [[Background Jobs and Queues]]
---
## Technical docs: CLI Tool
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/developer-tools/cli-tool
Relevant source files
The following files were used as context for generating this wiki page:
- [packages/cli/src/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts)
- [packages/cli/src/commands/links.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts)
- [packages/cli/package.json](https://github.com/blade47/dub/blob/HEAD/packages/cli/package.json)
- [packages/cli/src/commands/login.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts)
- [apps/web/app/api/links/route.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/links/route.ts)
- [packages/cli/src/types/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts)
- [apps/web/lib/integrations/slack/commands.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/integrations/slack/commands.ts)
- [packages/cli/src/api/links.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts)
- [apps/web/app/app.dub.co/onboarding/workspaces/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(onboarding)/workspaces/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/links/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/links/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/page-client.tsx)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/middleware.ts)
- [packages/cli/src/utils/get-package-info.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/get-package-info.ts)
- [packages/cli/src/utils/oauth.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts)
- [package.json](https://github.com/blade47/dub/blob/HEAD/package.json)
- [packages/cli/src/api/domains.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/domains.ts)
- [apps/web/app/app.dub.co/dashboard/slug/links/...link/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/links/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/links/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/links/components/disable-restore-workspace.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/links/components/disable-restore-workspace.tsx)
- [packages/ui/src/footer.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/footer.tsx)
- [packages/cli/src/utils/config.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts)
- [packages/cli/src/commands/domains.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts)
- [apps/web/app/app.dub.co/dashboard/slug/links/folders/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/folders/page.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/program/groups/groupSlug/links/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/program/groups/%5BgroupSlug%5D/links/page.tsx)
- [apps/web/app/ee/admin.dub.co/dashboard/domains/page.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/page.tsx)
- [apps/web/scripts/dub-wrapped.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/scripts/dub-wrapped.ts)
- [apps/web/app/app.dub.co/dashboard/slug/links/...link/page-client.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/links/%5B...link%5D/page-client.tsx)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/lib/dub.ts)
- [packages/ui/package.json](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json)
- [packages/cli/tsup.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsup.config.ts)
## Overview
The Dub CLI tool (`dub`) is a command-line interface designed to streamline URL shortening and link management directly from the terminal using the Dub API. It serves developers and platform users by offering native command execution, persistent local configuration storage, and secure authentication workflows that mirror core web capabilities without requiring a browser interface. Sources: [packages/cli/src/index.ts:17-19](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L17-L19), [packages/cli/package.json:2-4](https://github.com/blade47/dub/blob/HEAD/packages/cli/package.json#L2-L4)
## Entry Point and Command Registration
### Overview
The executable bootstrapping process initializes the `dub` command-line application, configuring signal handlers, loading package metadata, registering root command modules via `commander`, and executing argument parsing.
Sources: [packages/cli/src/index.ts:11-34](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L11-L34)
### Bootstrapping and Execution Flow
The entry point script `packages/cli/src/index.ts` begins with the Node.js environment hashbang `#!/usr/bin/env node` and registers global listeners for `SIGINT` and `SIGTERM` signals to ensure clean process termination via `process.exit(0)`.
Sources: [packages/cli/src/index.ts:1-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L1-L12)
The asynchronous `main()` function drives the root setup sequence:
1. `getPackageInfo()` is called to fetch remote package metadata for `dub-cli`.
2. A new `Command` instance is initialized with `.name("dub")`, `.description("A CLI for shortening links with the Dub API.")`, and `.version(packageInfo.version || "1.0.0", "-v, --version", "display the version number")`.
3. Subcommands are registered sequentially using `.addCommand()` for `login`, `config`, `domains`, `shorten`, and `links`.
4. `program.parse()` executes command resolution and argument evaluation.
Sources: [packages/cli/src/index.ts:14-34](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L14-L34), [packages/cli/src/utils/get-package-info.ts:4-7](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/get-package-info.ts#L4-L7)
> [!NOTE]
> `getPackageInfo()` retrieves metadata dynamically by querying `package-json` for `"dub-cli"`, falling back to `"1.0.0"` if the version field is undefined.
Sources: [packages/cli/src/utils/get-package-info.ts:4-7](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/get-package-info.ts#L4-L7), [packages/cli/src/index.ts:20-24](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L20-L24)
### Registered Commands Reference
| Command Variable | Source Path | Description / Purpose |
| :--- | :--- | :--- |
| `login` | `@/commands/login` | Authenticates user via OAuth and stores credentials. Sources: [packages/cli/src/index.ts:5-27](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L5-L27) |
| `config` | `@/commands/config` | Manages local CLI configuration settings. Sources: [packages/cli/src/index.ts:3-28](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L3-L28) |
| `domains` | `@/commands/domains` | Discovers and configures workspace domains. Sources: [packages/cli/src/index.ts:4-29](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L4-L29) |
| `shorten` | `@/commands/shorten` | Shortens URLs using the Dub API. Sources: [packages/cli/src/index.ts:6-30](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L6-L30) |
| `links` | `./commands/links` | Queries and manages shortened links. Sources: [packages/cli/src/index.ts:9-31](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L9-L31) |
Sources: [packages/cli/src/index.ts:3-31](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/index.ts#L3-L31)
### Build Configuration and Bundling
The package utilizes `tsup` for building the CLI bundle as defined in `packages/cli/tsup.config.ts`. The configuration specifies ECMAScript module output format (`esm`), target environment `esnext`, source maps enabled, code minification enabled, and declaration generation (`dts: true`) into the `dist` directory with `src/index.ts` as the primary entry point.
Sources: [packages/cli/tsup.config.ts:1-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/tsup.config.ts#L1-L12)
## OAuth Authentication Flow
### Overview
The `login` command orchestrates a browser-based OAuth authentication flow using PKCE, spinning up a local callback server listener to acquire API tokens from the Dub platform. Sources: [packages/cli/src/commands/login.ts:9-38](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L9-L38)
### OAuth Client Configuration and Flow Execution
The `oauthClient` instance is initialized via `@badgateway/oauth2-client` with a predefined client ID and explicit authorization and token endpoints on the Dub platform. Sources: [packages/cli/src/utils/oauth.ts:1-8](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L1-L8)
| Parameter | Value / Source | Purpose |
| :--- | :--- | :--- |
| `clientId` | `"dub_app_39527dcc11b452f38bb54a3a1664fd044d7158dfea8abcde"` | Identifies the Dub CLI application. Sources: [packages/cli/src/utils/oauth.ts:5-5](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L5-L5) |
| `authorizationEndpoint` | `"https://app.dub.co/oauth/authorize"` | Target URL for initiating user authorization. Sources: [packages/cli/src/utils/oauth.ts:6-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L6-L6) |
| `tokenEndpoint` | `"https://api.dub.co/oauth/token"` | Target URL for exchanging authorization codes for tokens. Sources: [packages/cli/src/utils/oauth.ts:7-7](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L7-L7) |
Sources: [packages/cli/src/utils/oauth.ts:3-8](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/oauth.ts#L3-L8)
The call-chain execution proceeds through the `login` command action handler:
1. `getNanoid(64)` generates a 64-character cryptographic `codeVerifier`. Sources: [packages/cli/src/commands/login.ts:14-14](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L14-L14)
2. `oauthClient.authorizationCode.getAuthorizeUri()` constructs the authorization URI using `redirectUri` (`http://localhost:4587/callback`), `codeVerifier`, and the required scopes. Sources: [packages/cli/src/commands/login.ts:15-21](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L15-L21)
3. `open(authUrl)` opens the browser for authentication while an `ora` spinner displays status updates. Sources: [packages/cli/src/commands/login.ts:23-27](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L23-L27)
4. `oauthCallbackServer()` spins up the local listener passing the client, redirect URI, code verifier, and spinner to finalize token acquisition. Sources: [packages/cli/src/commands/login.ts:29-34](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L29-L34)
> [!NOTE]
> The OAuth flow requests three specific permission scopes: `links.read`, `links.write`, and `domains.read`. Sources: [packages/cli/src/commands/login.ts:20-20](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L20-L20)
### OAuth Configuration Reference
| Option / Variable | Value / Setting | Description |
| :--- | :--- | :--- |
| `redirectUri` | `"http://localhost:4587/callback"` | Local redirect URI for capturing the OAuth callback code. Sources: [packages/cli/src/commands/login.ts:15-15](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L15-L15) |
| `codeVerifier` | `getNanoid(64)` | PKCE code verifier generated using a 64-character nanoid. Sources: [packages/cli/src/commands/login.ts:14-14](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L14-L14) |
| `scope` | `["links.read", "links.write", "domains.read"]` | Array of permission scopes requested during authorization. Sources: [packages/cli/src/commands/login.ts:20-20](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L20-L20) |
Sources: [packages/cli/src/commands/login.ts:14-21](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/login.ts#L14-L21)
## Configuration Persistence and State Management
### Overview
State management and credential persistence are handled through the `Configstore` library under the application identifier `"dub-cli"`. Active settings and tokens are structured according to the `DubConfig` interface and manipulated via dedicated retrieval and persistence utility functions. Sources: [packages/cli/src/types/index.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L1-L6), [packages/cli/src/utils/config.ts:3-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L3-L6)
### Schema Definition and Storage Format
The configuration schema defines authentication tokens, expiration metadata, and active workspace parameters. Sources: [packages/cli/src/types/index.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L1-L6)
| Field | Type | Description |
| :--- | :--- | :--- |
| `access_token` | `string` | The active OAuth access token for authenticating API requests. Sources: [packages/cli/src/types/index.ts:2-2](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L2-L2) |
| `refresh_token` | `string \| null` | The OAuth refresh token used to obtain new access tokens upon expiration. Sources: [packages/cli/src/types/index.ts:3-3](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L3-L3) |
| `expires_at` | `number \| null` | Epoch timestamp indicating when the current access token expires. Sources: [packages/cli/src/types/index.ts:4-4](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L4-L4) |
| `domain` | `string` (optional) | The active custom domain configured for link operations. Sources: [packages/cli/src/types/index.ts:5-5](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L5-L5) |
Sources: [packages/cli/src/types/index.ts:1-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/types/index.ts#L1-L6)
### Configuration Retrieval and Token Refresh Walkthrough
The configuration retrieval mechanism validates stored credentials and automatically triggers a token refresh if the access token has expired. Sources: [packages/cli/src/utils/config.ts:5-32](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L5-L32)
The call-chain execution proceeds through `getConfig()`:
1. `new Configstore("dub-cli")` instantiates the persistent store handler. Sources: [packages/cli/src/utils/config.ts:6-6](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L6-L6)
2. `configStore.size` is checked; if empty, it throws an error instructing the user to run `dub login`. Sources: [packages/cli/src/utils/config.ts:8-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L8-L12)
3. `configStore.all` is cast to `DubConfig`, and `config.expires_at` is evaluated against `Date.now()`. Sources: [packages/cli/src/utils/config.ts:14-16](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L14-L16)
4. If expired, `oauthClient.refreshToken()` is called with existing tokens and expiration data. Sources: [packages/cli/src/utils/config.ts:17-22](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L17-L22)
5. `setConfig()` updates the store with the newly acquired token set and returns the updated configuration object. Sources: [packages/cli/src/utils/config.ts:24-28](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L24-L28)
> [!WARNING]
> If `configStore.size` evaluates to zero, `getConfig()` immediately throws an error requiring re-authentication rather than returning an empty configuration object. Sources: [packages/cli/src/utils/config.ts:8-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L8-L12)
### State Mutation and Persistence Operations
The `setConfig()` function accepts partial configuration updates, merges them into the existing state store, and validates the disk path before returning the finalized configuration. Sources: [packages/cli/src/utils/config.ts:34-52](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L34-L52)
```typescript
export async function setConfig(
newConfig: Partial,
): Promise {
const configStore = new Configstore("dub-cli");
const existingConfig: DubConfig = configStore.all;
const updatedConfig: DubConfig = {
...existingConfig,
...newConfig,
};
configStore.set(updatedConfig);
if (!configStore.path) {
throw new Error("Failed to create or update config file");
}
return updatedConfig;
}
```
Sources: [packages/cli/src/utils/config.ts:34-52](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L34-L52)
> [!NOTE]
> Setting configuration values performs a shallow spread merge over existing configuration data, preserving unmentioned properties such as active tokens while updating specific fields like the active domain. Sources: [packages/cli/src/utils/config.ts:38-43](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/utils/config.ts#L38-L43)
## Link Management and Shortening
### Overview
The link management subsystem integrates Commander command definitions with the official Dub SDK to query workspaces and generate short links remotely. Operations retrieve local credentials via `getConfig()`, instantiate the `Dub` client with the stored access token, and format query results into a structured console table using `ora` spinners for terminal feedback.
Sources: [packages/cli/src/commands/links.ts:1-46](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L1-L46), [packages/cli/src/api/links.ts:1-16](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts#L1-L16)
### Command Mechanics and SDK Integration
The `links` command exposes search and pagination options using Commander.
Sources: [packages/cli/src/commands/links.ts:7-12](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L7-L12)
| Option Flag | Argument Type | Default / Fallback | Description | Sources |
| --- | --- | --- | --- | --- |
| `-s, --search` | `[search]` | `undefined` | Search term to filter links by | Sources: [packages/cli/src/commands/links.ts:10-10](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L10-L10) |
| `-l, --limit` | `[limit]` | `10` (parsed integer) | Number of links to fetch | Sources: [packages/cli/src/commands/links.ts:11-11](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L11-L11), [packages/cli/src/commands/links.ts:24-24](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L24-L24) |
Sources: [packages/cli/src/commands/links.ts:10-24](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L10-L24)
### Link Search Call-Chain Walkthrough
The link search execution path processes user input through configuration loading, SDK querying, and tabular formatting:
1. `getConfig()` retrieves the active OAuth access token from persistent storage.
2. `ora("Fetching links").start()` initiates a terminal spinner animation during network traversal.
3. `new Dub({ token })` instantiates the SDK client using the retrieved access token.
4. `dub.links.list({ search, pageSize })` queries remote workspace links, parsing `limit` via `parseInt` or falling back to `10`.
5. `spinner.stop()` halts the CLI loading animation upon response receipt.
6. `links.result.map()` transforms raw link objects into structured key-value pairs (`Short Link`, `Destination URL`, `Clicks`, and localized `Created At` dates), which are rendered via `console.table()`.
Sources: [packages/cli/src/commands/links.ts:13-42](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L13-L42)
> [!WARNING]
> Any uncaught errors during configuration retrieval, Dub client initialization, or remote API execution are intercepted by the catch block and piped directly into `handleError(error)`.
Sources: [packages/cli/src/commands/links.ts:43-45](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/links.ts#L43-L45)
### Remote Link Generation API
The `createLink` helper function provisions new short links against the configured workspace domain.
Sources: [packages/cli/src/api/links.ts:4-16](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts#L4-L16)
```typescript
export async function createLink({ url, key }: { url: string; key: string }) {
const config = await getConfig();
const dub = new Dub({
token: config.access_token,
});
return await dub.links.create({
domain: config.domain,
url: url,
key: key,
});
}
```
Sources: [packages/cli/src/api/links.ts:4-16](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts#L4-L16)
> [!TIP]
> The `createLink` function automatically binds the generated short link to `config.domain` retrieved from the active workspace settings alongside the provided destination `url` and custom slug `key`.
Sources: [packages/cli/src/api/links.ts:5-15](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/links.ts#L5-L15)
## Domain Operations and Workspace Context
### Overview
The `domains` command manages workspace domain configuration by retrieving available custom and default domains, presenting an interactive selection menu via `prompts`, and saving the chosen domain to local storage.
Sources: [packages/cli/src/commands/domains.ts:17-69](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L17-L69)
### Domain Discovery Call-Chain Walkthrough
The domain discovery workflow aggregates available domain slugs from both the Dub SDK and direct platform APIs:
1. `getConfig()` reads the local access token from disk configuration.
2. `new Dub({ token })` instantiates the Dub client.
3. `Promise.all()` executes concurrent requests to fetch custom domains via `dub.domains.list()` and default domains via a `fetch` call to `https://api.dub.co/domains/default`.
4. `parseApiResponse(defaultDomainsResponse)` parses the raw HTTP response body into a string array.
5. Array mapping extracts custom domain `.slug` properties, combines them with default domains, and passes the concatenated list into `Array.from(new Set(allSlugs))` to eliminate duplicate entries before returning the unique slugs.
Sources: [packages/cli/src/api/domains.ts:7-33](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/domains.ts#L7-L33)
> [!NOTE]
> `getDomains` performs concurrent retrieval against both the SDK domain listing endpoint and the default domains route, unifying custom workspace domains and fallback options into a single deduplicated array.
Sources: [packages/cli/src/api/domains.ts:13-33](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/api/domains.ts#L13-L33)
### Interactive Selection and Configuration Persistence
The `domains` command presents an interactive terminal prompt using `ora` and `prompts`, validating user selection against a Zod schema before persisting the result.
Sources: [packages/cli/src/commands/domains.ts:17-69](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L17-L69)
| Component / Function | Type / Library | Purpose / Validation Behavior | Sources |
| --- | --- | --- | --- |
| `domainOptionsSchema` | Zod Object (`z/v4`) | Validates that the selected domain slug has a minimum length of 3 characters with message `"Please provide a valid slug"`. | Sources: [packages/cli/src/commands/domains.ts:11-15](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L11-L15) |
| Spinner (`ora`) | `ora` | Displays an active `"Fetching domains"` loader while `getDomains()` resolves. | Sources: [packages/cli/src/commands/domains.ts:21-26](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L21-L26) |
| Prompt (`prompts`) | `prompts` | Renders a select dropdown using the fetched choices and executes `domainOptionsSchema.shape.slug.safeParse(value)`. | Sources: [packages/cli/src/commands/domains.ts:36-47](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L36-L47) |
| Cancellation Handler | `onCancel` callback | Intercepts prompt cancellation, logs a warning via `logger.warn("You canceled the prompt.")`, and terminates the process with `process.exit(0)`. | Sources: [packages/cli/src/commands/domains.ts:49-56](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L49-L56) |
| Persistence | `setConfig` | Saves the validated selection via `setConfig({ domain: options.domain })` and outputs success logs using `chalk.green`. | Sources: [packages/cli/src/commands/domains.ts:59-64](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L59-L64) |
Sources: [packages/cli/src/commands/domains.ts:11-64](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L11-L64)
> [!CAUTION]
> If a user cancels the interactive domain prompt via keyboard interruption or escape, the `onCancel` handler forces an immediate process termination (`process.exit(0)`), preventing subsequent configuration writes.
Sources: [packages/cli/src/commands/domains.ts:49-56](https://github.com/blade47/dub/blob/HEAD/packages/cli/src/commands/domains.ts#L49-L56)
## Related
- [[OpenAPI and Public REST API]]
---
## Technical docs: POST Send Slack support channel invite
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/api/admin/slacksupportinvite
## Request Body
Invite details including email/emails and workspaceSlug
## Responses
## Try It
---
## Technical docs: UI Component Library
URL: https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/developer-tools/ui-component-library
Relevant source files
The following files were used as context for generating this wiki page:
- [packages/ui/tailwind.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/tailwind.config.ts)
- [packages/ui/package.json](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json)
- [packages/ui/src/index.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/index.tsx)
- [packages/embeds/react/tailwind.config.ts](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/tailwind.config.ts)
- [packages/ui/src/content.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/content.ts)
- [packages/ui/src/styles.css](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/styles.css)
- [packages/ui/src/footer.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/footer.tsx)
- [apps/web/ui/modals/qr-code-design-fields.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/qr-code-design-fields.tsx)
- [packages/tailwind-config/package.json](https://github.com/blade47/dub/blob/HEAD/packages/tailwind-config/package.json)
- [packages/ui/src/date-picker/presets.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/date-picker/presets.tsx)
- [packages/ui/src/nav/nav.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/nav.tsx)
- [packages/ui/src/nav/content/shared.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/content/shared.tsx)
- [packages/ui/src/accordion.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/accordion.tsx)
- [packages/ui/src/label.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/label.tsx)
- [packages/ui/src/carousel/carousel.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/carousel/carousel.tsx)
- [packages/ui/src/alert.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/alert.tsx)
- [apps/web/app/app.dub.co/dashboard/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/layout.tsx)
- [apps/web/app/app.dub.co/dashboard/slug/ee/settings/tracking/stack-picker.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/%5Bslug%5D/(ee)/settings/tracking/stack-picker.tsx)
- [packages/ui/src/sheet.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/sheet.tsx)
- [apps/web/app/domain/layout.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/%5Bdomain%5D/layout.tsx)
- [packages/ui/src/tooltip.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/tooltip.tsx)
- [packages/ui/src/nav/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/nav/index.ts)
- [packages/ui/src/charts/index.ts](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/charts/index.ts)
- [apps/web/ui/modals/modal-provider.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/modals/modal-provider.tsx)
- [apps/web/ui/shared/emoji-picker.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/ui/shared/emoji-picker.tsx)
- [packages/ui/src/button.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/button.tsx)
- [packages/ui/src/modal.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/modal.tsx)
- [apps/web/app/app.dub.co/dashboard/loading.tsx](https://github.com/blade47/dub/blob/HEAD/apps/web/app/app.dub.co/(dashboard)/loading.tsx)
- [packages/ui/src/radio-group.tsx](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/radio-group.tsx)
- [apps/web/app/api/callback/plain/utils.ts](https://github.com/blade47/dub/blob/HEAD/apps/web/app/api/callback/plain/utils.ts)
## Overview
The Dub UI Component Library (`@dub/ui`) is a comprehensive design system and React component package engineered to power the Dub web application and ecosystem. Built on top of Radix UI primitives, Tailwind CSS, and Class Variance Authority (CVA), it provides a robust foundation for building accessible, responsive, and type-safe interfaces. The library centralizes design tokens, styling presets, interactive controls, and specialized domain components—ranging from time-series charts and rich-text areas to adaptive modals and navigation bars—ensuring visual consistency and rapid feature development across all Dub properties.
Sources: [packages/ui/package.json:2-115](https://github.com/blade47/dub/blob/HEAD/packages/ui/package.json#L2-L115), [packages/ui/src/index.tsx:1-87](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/index.tsx#L1-L87)
## Design System Architecture and Styling
### Overview
The design system architecture relies on a structured monorepo package layout that separates shared styling tokens from component implementations. The core UI package `@dub/ui` consumes Tailwind configuration presets from `@dub/tailwind-config`, ensuring consistent token sharing across packages like `@dub/embeds/react`. Base CSS injection is handled centrally at the entry point of the package.
Sources: [packages/ui/tailwind.config.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/ui/tailwind.config.ts#L1-L10), [packages/ui/src/index.tsx:1-3](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/index.tsx#L1-L3), [packages/embeds/react/tailwind.config.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/tailwind.config.ts#L1-L10)
### Tailwind Presets and Base Styles
The styling pipeline integrates Tailwind CSS base, component, and utility layers through a dedicated stylesheet. Both `@dub/ui` and `@dub/embeds/react` extend the shared Tailwind configuration via the `presets` property to maintain uniform design constraints.
```typescript
import sharedConfig from "@dub/tailwind-config/tailwind.config.ts";
import type { Config } from "tailwindcss";
const config: Pick = {
presets: [sharedConfig],
};
export default config;
```
Sources: [packages/ui/tailwind.config.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/ui/tailwind.config.ts#L1-L10), [packages/embeds/react/tailwind.config.ts:1-10](https://github.com/blade47/dub/blob/HEAD/packages/embeds/react/tailwind.config.ts#L1-L10)
The base style directives load the Tailwind layer architecture directly into the CSS bundle consumed by the component entry point.
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Sources: [packages/ui/src/styles.css:1-3](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/styles.css#L1-L3), [packages/ui/src/index.tsx:1-2](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/index.tsx#L1-L2)
### Monorepo Integration and Dependencies
The `@dub/tailwind-config` package bundles foundational Tailwind plugins and utility extensions that govern typography, forms, container queries, scrollbars, and Radix state integrations.
| Dependency Package | Version | Purpose |
| :--- | :--- | :--- |
| `@tailwindcss/container-queries` | `^0.1.1` | Enables container query utilities |
| `@tailwindcss/forms` | `^0.5.6` | Resets form element styling |
| `@tailwindcss/typography` | `^0.5.9` | Provides prose styling classes |
| `tailwind-scrollbar-hide` | `^1.1.7` | Utilities for hiding scrollbars |
| `tailwindcss-radix` | `^2.8.0` | Radix UI state variant integration |
Sources: [packages/tailwind-config/package.json:10-16](https://github.com/blade47/dub/blob/HEAD/packages/tailwind-config/package.json#L10-L16)
## Interactive Primitives and Variants
### Overview
The interactive primitive and form control layer of `@dub/ui` combines Radix UI headless components with Class Variance Authority (`cva`) and the `@dub/utils` `cn` class-merging helper. This architecture powers base components such as `Button`, `Tooltip`, `Label`, `Alert`, and `RadioGroup`, maintaining strict type safety, accessibility attributes, and dynamic styling variants across the library.
Sources: [packages/ui/src/label.tsx:1-24](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/label.tsx#L1-L24), [packages/ui/src/alert.tsx:1-64](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/alert.tsx#L1-L64), [packages/ui/src/tooltip.tsx:1-288](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/tooltip.tsx#L1-L288), [packages/ui/src/button.tsx:1-158](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/button.tsx#L1-L158), [packages/ui/src/radio-group.tsx:1-44](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/radio-group.tsx#L1-L44)
### Button Variants and Execution Lifecycle
The `Button` component supports multiple visual intents and states, including loading indicators, keyboard shortcut badges, icons, and disabled tooltips. When `disabledTooltip` is provided, the component renders a disabled container wrapped in a `Tooltip` rather than a standard button element.
| Variant | Styling Class Definition | Purpose / Interaction State |
| :--- | :--- | :--- |
| `primary` | `border-black bg-black dark:bg-white dark:border-white text-content-inverted hover:bg-inverted hover:ring-4 hover:ring-border-subtle` | High-emphasis primary actions |
| `secondary` | `border-border-subtle bg-bg-default text-content-emphasis hover:bg-bg-muted focus-visible:border-border-emphasis outline-none data-[state=open]:border-border-emphasis data-[state=open]:ring-4 data-[state=open]:ring-border-subtle` | Secondary or contextual actions, supporting open state rings |
| `outline` | `border-transparent text-content-default hover:bg-neutral-900/5` | Low-emphasis borderless buttons |
| `success` | `border-blue-500 bg-blue-500 text-white hover:bg-blue-600 hover:ring-4 hover:ring-blue-100` | Positive state actions |
| `danger` | `border-red-500 bg-red-500 text-white hover:bg-red-600 hover:ring-4 hover:ring-red-100` | Destructive high-emphasis actions |
| `danger-outline` | `border-transparent bg-white text-red-500 hover:bg-red-600 hover:text-white` | Destructive low-emphasis actions |
Sources: [packages/ui/src/button.tsx:7-28](https://github.com/blade47/dub/blob/HEAD/packages/ui/src/button.tsx#L7-L28)
The button execution flow determines whether an interaction triggers form submission or handles click events directly based on props:
```
Button Input Props (onClick, disabled, loading, disabledTooltip)
│
├─► Has disabledTooltip? ──► Render +
(cursor-not-allowed, shortcut, icon)
│
└─► No disabledTooltip? ──► Render