# 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 (
); } ``` 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 (
); } ``` 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 (

Submitted content

{lastSyncedAt && ( Last sync{" "} {formatDistanceToNow(new Date(lastSyncedAt), { addSuffix: true })} )}
{/* 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 (
{ try { const res = await fetch("/api/admin/slack-support-invite", { method: "POST", body: JSON.stringify({ email: data.get("email"), workspaceSlug: data.get("workspaceSlug"), channelId: data.get("channelId") || undefined, }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { if (json.nameTaken) { setNeedsChannelId(true); } toast.error( json.error ?? "Something went wrong. Please try again.", ); return; } setNeedsChannelId(false); toast.success(`Slack invite sent (ID: ${json.inviteId})`); } catch { toast.error( "Network error. Please check your connection and try again.", ); } }} >
); } ``` 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 (
{ e.preventDefault(); setClickedMethod("saml"); fetch("/api/auth/saml/verify", { method: "POST", body: JSON.stringify({ slug: e.currentTarget.slug.value }), }).then(async (res) => { const { data, error } = await res.json(); if (error) { toast.error(error); setClickedMethod(undefined); return; } setLastUsedAuthMethod("saml"); await signIn("saml", undefined, { tenant: data.workspaceId, product: "Dub", }); }); }} className="flex flex-col space-y-3" > {showSSOOption && (
{authMethod !== "saml" && (
)}

Workspace Slug

)}