diff --git a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts index 5c66da70db0..7c2b2ff8769 100644 --- a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts +++ b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts @@ -11,6 +11,7 @@ import { extractCardPlainText } from '../../shared/util/card-plain-text.util'; import { toDeliveryError } from '../../shared/util/delivery-error.util'; import { esmImport } from '../../shared/util/esm-import'; import { buildBrandedMarkdownReply, contentHasPoweredByWatermark } from '../../shared/util/novu-powered-by-watermark'; +import { splitOversizedSlackText } from '../../shared/util/slack-section-limits'; import { type AgentActionTokenBinding, AgentActionTokenService } from '../action-token/agent-action-token.service'; import { AgentConversationService } from '../conversation/agent-conversation.service'; import { ChatInstanceRegistry, type ChatWithAdapters, type PlatformAdapters } from '../ingress/chat-instance.registry'; @@ -932,7 +933,10 @@ export class OutboundGateway { if (deliveryContent.card) { return { - card: deliveryContent.card, + card: + branding.platform === AgentPlatformEnum.SLACK + ? splitOversizedSlackText(deliveryContent.card) + : deliveryContent.card, ...(deliveryContent.files?.length ? { files: deliveryContent.files } : {}), } as AdapterPostableMessage; } diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts b/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts index 836fb001789..bb221ae16ce 100644 --- a/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts +++ b/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts @@ -5,6 +5,7 @@ import type { WhatsAppAdapter } from '@chat-adapter/whatsapp'; import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common'; import { CacheService, PinoLogger } from '@novu/application-generic'; import type { NovuAgentChatAdapter } from '@novu/chat-adapter-agent-chat'; +import { stripAgentReplyToken } from '@novu/shared'; import type { Adapter, Chat, Message, ReactionEvent, SlashCommandEvent, Thread } from 'chat'; import { LRUCache } from 'lru-cache'; import { resolveWhatsAppAppSecret } from '../../../integrations/usecases/whatsapp/whatsapp-credentials.utils'; @@ -465,6 +466,7 @@ export class ChatInstanceRegistry implements OnModuleDestroy { senderName: resolveAgentEmailSenderName(config), signingSecret: credentials.secretKey, sendEmail: this.agentEmailSender.buildSendEmailCallback(config, outboundIntegrationId), + stripAgentReplyToken, actionUrlBuilder: async ({ threadId, messageId, actionId, value, label, style }) => { const userIdentifier = extractRecipientFromThreadId(threadId); const { url } = await this.emailActionTokenService.signActionToken({ diff --git a/apps/api/src/app/agents/shared/agent-event-sink.service.ts b/apps/api/src/app/agents/shared/agent-event-sink.service.ts index 4199c20654d..fbcb18c67d4 100644 --- a/apps/api/src/app/agents/shared/agent-event-sink.service.ts +++ b/apps/api/src/app/agents/shared/agent-event-sink.service.ts @@ -652,9 +652,12 @@ export class AgentEventSink { } if (approvals.length === 0) { - this.logger.error({ runId }, 'paused run-finish carried zero approvals — skipping tool approval dispatch'); - - return; + // Empty when the pending tool_use came from an earlier run (resumed streams are a live + // tail). HandlePendingToolApprovals recovers from the session. + this.logger.warn( + { runId, sessionId }, + 'paused run-finish carried zero approvals — recovering pending approvals from the session' + ); } try { diff --git a/apps/api/src/app/agents/shared/util/slack-section-limits.spec.ts b/apps/api/src/app/agents/shared/util/slack-section-limits.spec.ts new file mode 100644 index 00000000000..5acbb0531b3 --- /dev/null +++ b/apps/api/src/app/agents/shared/util/slack-section-limits.spec.ts @@ -0,0 +1,69 @@ +import { expect } from 'chai'; +import type { CardElement } from 'chat'; + +import { splitForSlackSections, splitOversizedSlackText } from './slack-section-limits'; + +const LIMIT = 3000; +const LONG_MARKDOWN = Array.from( + { length: 40 }, + (_, index) => `Paragraph ${index}: ${'restaurant details '.repeat(12)}` +).join('\n\n'); + +describe('slack-section-limits', () => { + describe('splitForSlackSections', () => { + it('leaves text within the section limit untouched', () => { + expect(splitForSlackSections('short reply')).to.deep.equal(['short reply']); + }); + + it('splits a long reply on paragraph breaks without losing content', () => { + const chunks = splitForSlackSections(LONG_MARKDOWN); + + expect(chunks.length).to.be.greaterThan(1); + expect(chunks.every((chunk) => chunk.length <= LIMIT)).to.equal(true); + expect(chunks.join('\n\n')).to.equal(LONG_MARKDOWN); + }); + + it('splits text that has no paragraph or line breaks to fall back on', () => { + const unbroken = 'x'.repeat(7500); + const chunks = splitForSlackSections(unbroken); + + expect(chunks.every((chunk) => chunk.length <= LIMIT)).to.equal(true); + expect(chunks.join('')).to.equal(unbroken); + }); + + it('prefers a space near the limit over a mid-word hard cut', () => { + const head = `${'a'.repeat(2900)} `; + const tail = 'b'.repeat(200); + const line = `${head}${tail}`; + const chunks = splitForSlackSections(line); + + expect(chunks).to.deep.equal([head, tail]); + expect(chunks.join('')).to.equal(line); + }); + }); + + describe('splitOversizedSlackText', () => { + it('returns the same card when every text child fits', () => { + const card: CardElement = { type: 'card', children: [{ type: 'text', content: 'hello' }] }; + + expect(splitOversizedSlackText(card)).to.equal(card); + }); + + it('expands an oversized text child and keeps the trailing watermark', () => { + const card: CardElement = { + type: 'card', + children: [ + { type: 'text', content: LONG_MARKDOWN }, + { type: 'text', content: 'Powered by ', style: 'muted' }, + ], + }; + + const children = splitOversizedSlackText(card).children; + const textChildren = children.filter((child) => child.type === 'text'); + + expect(children.length).to.be.greaterThan(card.children.length); + expect(textChildren.every((child) => child.content.length <= LIMIT)).to.equal(true); + expect(textChildren[textChildren.length - 1]).to.deep.equal(card.children[1]); + }); + }); +}); diff --git a/apps/api/src/app/agents/shared/util/slack-section-limits.ts b/apps/api/src/app/agents/shared/util/slack-section-limits.ts new file mode 100644 index 00000000000..3a6c9e6eac1 --- /dev/null +++ b/apps/api/src/app/agents/shared/util/slack-section-limits.ts @@ -0,0 +1,99 @@ +import type { CardElement } from 'chat'; + +/** + * Slack's per-section text limit. Crossing it does not truncate the section — Slack rejects + * the entire `chat.postMessage` payload with `invalid_blocks`, so the reply is lost instead + * of shortened. + */ +const SLACK_SECTION_TEXT_LIMIT = 3000; + +function pack(parts: string[], separator: string, limit: number, splitPart: (part: string) => string[]): string[] { + const chunks: string[] = []; + let current = ''; + + for (const part of parts) { + const candidate = current ? `${current}${separator}${part}` : part; + + if (candidate.length <= limit) { + current = candidate; + + continue; + } + + if (current) { + chunks.push(current); + current = ''; + } + + if (part.length <= limit) { + current = part; + + continue; + } + + chunks.push(...splitPart(part)); + } + + if (current) { + chunks.push(current); + } + + return chunks; +} + +/** Last resort for a single oversize line: prefer a nearby space, else hard-cut. */ +function sliceToLimit(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > limit) { + const window = remaining.slice(0, limit); + const spaceAt = window.lastIndexOf(' '); + const cut = spaceAt > Math.floor(limit / 2) ? spaceAt + 1 : limit; + + chunks.push(remaining.slice(0, cut)); + remaining = remaining.slice(cut); + } + + if (remaining) { + chunks.push(remaining); + } + + return chunks; +} + +/** Prefer paragraph breaks, then line breaks, then a space-aware cut at `limit`. */ +export function splitForSlackSections(text: string, limit: number = SLACK_SECTION_TEXT_LIMIT): string[] { + if (text.length <= limit) { + return [text]; + } + + return pack(text.split('\n\n'), '\n\n', limit, (paragraph) => + pack(paragraph.split('\n'), '\n', limit, (line) => sliceToLimit(line, limit)) + ); +} + +/** + * Expands over-long text children into within-limit ones. The Slack adapter maps one text + * child to one section block; oversize sections are rejected as `invalid_blocks`. + */ +export function splitOversizedSlackText(card: CardElement): CardElement { + const needsSplit = card.children.some( + (child) => child.type === 'text' && child.content.length > SLACK_SECTION_TEXT_LIMIT + ); + + if (!needsSplit) { + return card; + } + + return { + ...card, + children: card.children.flatMap((child) => { + if (child.type !== 'text' || child.content.length <= SLACK_SECTION_TEXT_LIMIT) { + return [child]; + } + + return splitForSlackSections(child.content).map((content) => ({ ...child, content })); + }), + }; +} diff --git a/apps/dashboard/src/components/onboarding/personalize/channel-chip.tsx b/apps/dashboard/src/components/onboarding/personalize/channel-chip.tsx index 820e51a6945..92d0133a37d 100644 --- a/apps/dashboard/src/components/onboarding/personalize/channel-chip.tsx +++ b/apps/dashboard/src/components/onboarding/personalize/channel-chip.tsx @@ -3,7 +3,7 @@ import { cn } from '@/utils/ui'; type ChannelChipProps = { label: string; - icon: ReactNode; + icon?: ReactNode; accent: string; isSelected: boolean; onToggle: () => void; @@ -21,7 +21,7 @@ export function ChannelChip({ label, icon, accent, isSelected, onToggle }: Chann )} style={isSelected ? { backgroundColor: `${accent}1f`, borderColor: `${accent}3d` } : undefined} > - {icon} + {icon ? {icon} : null} {label} ); diff --git a/apps/dashboard/src/components/onboarding/personalize/personalize-options.tsx b/apps/dashboard/src/components/onboarding/personalize/personalize-options.tsx index f2da604f3b7..9deef0f3c6d 100644 --- a/apps/dashboard/src/components/onboarding/personalize/personalize-options.tsx +++ b/apps/dashboard/src/components/onboarding/personalize/personalize-options.tsx @@ -1,5 +1,5 @@ import { ChatProviderIdEnum, EmailProviderIdEnum } from '@novu/shared'; -import { Mails } from 'lucide-react'; +import { AtSign, Hash, Mails } from 'lucide-react'; import type { ReactNode } from 'react'; import { AGENT_IMESSAGE_LABEL, getAgentChannelIconFileName } from '@/utils/agent-channel-branding'; @@ -12,6 +12,9 @@ export type AgentReadiness = 'live_in_production' | 'in_development' | 'planned_ export type AgentAudience = 'customers_end_users' | 'employees_internal_teams' | 'both' | 'not_sure_yet'; +/** How users and the agent initiate conversations — optional survey answer. */ +export type AgentInteraction = 'agent_reaches_out_or_asks' | 'users_message_or_tag' | 'both'; + /** Survey channel ids only — real provider ids so they join the rest of the agent funnel. */ export const AGENT_CHANNEL_VALUES = [ EmailProviderIdEnum.NovuAgent, @@ -112,3 +115,34 @@ export const AGENT_CHANNEL_OPTIONS: ChannelOption[] = [ accent: '#DC224E', }, ]; + +export type InteractionOption = { + value: AgentInteraction; + label: string; + icon?: ReactNode; + /** Brand colour the chip tints itself with while selected. */ + accent: string; +}; + +/** Neutral accent shared by interaction chips (no per-option brand colour). */ +const INTERACTION_ACCENT = '#525866'; + +export const AGENT_INTERACTION_OPTIONS: InteractionOption[] = [ + { + value: 'agent_reaches_out_or_asks', + label: 'Agent reaches out for input (human-in-the-loop)', + icon: , + accent: INTERACTION_ACCENT, + }, + { + value: 'users_message_or_tag', + label: 'Users message or tag the agent', + icon: , + accent: INTERACTION_ACCENT, + }, + { + value: 'both', + label: 'Both', + accent: INTERACTION_ACCENT, + }, +]; diff --git a/apps/dashboard/src/pages/agents-personalize-page.tsx b/apps/dashboard/src/pages/agents-personalize-page.tsx index dc158043550..1a14ad31e52 100644 --- a/apps/dashboard/src/pages/agents-personalize-page.tsx +++ b/apps/dashboard/src/pages/agents-personalize-page.tsx @@ -9,9 +9,11 @@ import { ChannelChip } from '@/components/onboarding/personalize/channel-chip'; import { AGENT_AUDIENCE_OPTIONS, AGENT_CHANNEL_OPTIONS, + AGENT_INTERACTION_OPTIONS, AGENT_READINESS_OPTIONS, type AgentAudience, type AgentChannel, + type AgentInteraction, type AgentReadiness, type PersonalizeOption, } from '@/components/onboarding/personalize/personalize-options'; @@ -128,6 +130,40 @@ function ChannelQuestion({ ); } +/** + * Optional single-select chips. Clicking the selected chip again clears the answer so the + * question stays skippable without a required pick. + */ +function InteractionQuestion({ + selected, + onSelect, +}: { + selected: AgentInteraction | undefined; + onSelect: (value: AgentInteraction) => void; +}) { + const labelId = useId(); + + return ( +
+ +
+ {AGENT_INTERACTION_OPTIONS.map((option) => ( + onSelect(option.value)} + /> + ))} +
+
+ ); +} + export function AgentsPersonalizePage() { const areAgentsAvailable = useAreConversationalAgentsAvailable(); const isLaunchDarklyReady = useLaunchDarklyReady(); @@ -140,6 +176,7 @@ export function AgentsPersonalizePage() { const [readiness, setReadiness] = useState(undefined); const [audience, setAudience] = useState(undefined); const [channels, setChannels] = useState([]); + const [interaction, setInteraction] = useState(undefined); // `product_type=agents` signups land here without ever seeing the picker, so sending them "back" // to it would push them into a screen that defaults to Inbox and reverses their choice. Setup may @@ -181,11 +218,23 @@ export function AgentsPersonalizePage() { }); }; + const handleInteractionSelect = (value: AgentInteraction) => { + const next = interaction === value ? undefined : value; + + setInteraction(next); + telemetry(TelemetryEvent.ONBOARDING_PERSONALIZE_ANSWERED, { + question: 'agent_interaction', + value, + selected: next !== undefined, + }); + }; + const handleContinue = () => { telemetry(TelemetryEvent.ONBOARDING_PERSONALIZE_SUBMITTED, { agentReadiness: readiness, agentAudience: audience, agentChannels: channels, + agentInteraction: interaction, }); // The agents setup page waits on the org, so the loader plays across that hand-off. beginOnboardingProvisioning('agents'); @@ -243,6 +292,10 @@ export function AgentsPersonalizePage() { + + + + diff --git a/docs/docs.json b/docs/docs.json index 82d98421d1f..fad83917ee2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -385,6 +385,7 @@ "platform/developer/environments", "platform/developer/environment-variables", "platform/developer/limits", + "platform/developer/delivery-retries", { "group": "Inbound email", "pages": ["platform/inbound-email/overview"] @@ -892,51 +893,67 @@ { "item": "Guides", "groups": [ - { - "group": "Use Cases", - "pages": [ - "guides/use-cases/transactional-notifications", - "guides/use-cases/password-reset-notifications", - "guides/use-cases/digest-notifications", - "guides/use-cases/multi-channel-fallback", - "guides/use-cases/notification-preferences" - ], - "icon": "lightbulb" - }, { "group": "Guides", - "pages": ["guides", "guides/inngest", "guides/triggerdotdev"], - "icon": "book-open" - }, - { - "group": "Inbound Webhooks", - "pages": [ - "guides/webhooks/clerk", - "guides/webhooks/auth0", - "guides/webhooks/stripe", - "guides/webhooks/segment" - ], - "icon": "webhook" - }, - { - "group": "Recipes", - "pages": ["guides/recipes/managing-workflows", "guides/recipes/supabase-auth-notifications"], - "icon": "chef-hat" - }, - { - "group": "Framework", - "pages": ["guides/framework/using-translations"], - "icon": "blocks" - }, - { - "group": "Migrate to Novu", + "icon": "book-open", "pages": [ - "guides/migrate-from-courier-to-novu", - "guides/migrate-from-knock-to-novu", - "guides/migrate-from-magicbell-to-novu", - "guides/migrate-from-in-house-to-novu" - ], - "icon": "arrow-right-left" + "guides", + { + "group": "Use Cases", + "expanded": false, + "pages": [ + "guides/use-cases/transactional-notifications", + "guides/use-cases/password-reset-notifications", + "guides/use-cases/digest-notifications", + "guides/use-cases/multi-channel-fallback", + "guides/use-cases/notification-preferences" + ] + }, + { + "group": "Workflow automation", + "expanded": false, + "pages": ["guides/inngest", "guides/triggerdotdev"] + }, + { + "group": "Webhooks", + "expanded": false, + "pages": [ + "guides/webhooks/clerk", + "guides/webhooks/auth0", + "guides/webhooks/stripe" + ] + }, + { + "group": "Analytics & data", + "expanded": false, + "pages": [ + "guides/analytics/segment", + "guides/analytics/hightouch" + ] + }, + { + "group": "Recipes", + "expanded": false, + "pages": [ + "guides/recipes/managing-workflows", + "guides/recipes/supabase-auth-notifications" + ] + }, + { + "group": "Framework", + "expanded": false, + "pages": ["guides/framework/using-translations"] + }, + { + "group": "Migrate to Novu", + "pages": [ + "guides/migrate-from-courier-to-novu", + "guides/migrate-from-knock-to-novu", + "guides/migrate-from-magicbell-to-novu", + "guides/migrate-from-in-house-to-novu" + ] + } + ] } ] }, @@ -1192,7 +1209,22 @@ }, { "source": "/guides/integrations/segment", - "destination": "/platform/integrations/segment", + "destination": "/guides/analytics/segment", + "permanent": true + }, + { + "source": "/guides/webhooks/segment", + "destination": "/guides/analytics/segment", + "permanent": true + }, + { + "source": "/platform/integrations/segment", + "destination": "/guides/analytics/segment", + "permanent": true + }, + { + "source": "/guides/webhooks/hightouch", + "destination": "/guides/analytics/hightouch", "permanent": true }, { diff --git a/docs/guides.mdx b/docs/guides.mdx index 3a58a9dd244..c3cc76e7add 100644 --- a/docs/guides.mdx +++ b/docs/guides.mdx @@ -1,6 +1,6 @@ --- title: 'Novu Integration Guides and Recipes' -description: "Step-by-step Novu integration guides for Clerk, Stripe, Segment, Auth0, Supabase, and other services to add notifications to your product stack." +description: "Step-by-step Novu integration guides for Clerk, Stripe, Segment, Hightouch, Auth0, Supabase, and other services to add notifications to your product stack." sidebarTitle: Overview --- @@ -28,7 +28,7 @@ Novu provides various ways to integrate with external services to trigger notifi ### Webhook Integration Guides -Webhooks enable real-time event-driven communication between applications, making integrations more efficient and responsive. +Webhooks enable real-time event-driven communication between applications, making integrations more efficient and responsive. In Novu, webhooks trigger notification workflows whenever specific events occur in external applications. This ensures notifications are delivered exactly when they're needed, keeping users informed without delay. @@ -39,7 +39,7 @@ This allows for real-time notifications - whether it's a welcome email, payment

- Use Clerks webhooks events to trigger authentication related notifications workflows. + Use Clerk webhook events to trigger authentication-related notification workflows.

@@ -49,7 +49,7 @@ This allows for real-time notifications - whether it's a welcome email, payment

- Use Stripe webhooks events to trigger payment related notifications workflows. + Use Stripe webhook events to trigger payment-related notification workflows.

@@ -59,9 +59,14 @@ This allows for real-time notifications - whether it's a welcome email, payment Some integrations use different mechanisms than webhooks to send data to Novu. For example, analytics and data platforms often use custom destinations or functions to forward events. - +

- Use Segment's Destination Functions to forward user events and traits to trigger notification workflows. + Use Segment Destination Functions to forward user events and traits into Novu workflows. +

+
+ +

+ Sync warehouse models to Novu with Hightouch's HTTP Request destination to create subscribers and trigger workflows.

@@ -81,4 +86,4 @@ Workflow automation platforms help orchestrate complex business processes and ev Leverage Trigger.dev's developer-friendly workflow engine to send notifications based on scheduled or event-driven triggers.

- \ No newline at end of file + diff --git a/docs/guides/analytics/hightouch.mdx b/docs/guides/analytics/hightouch.mdx new file mode 100644 index 00000000000..aaf56fb65dc --- /dev/null +++ b/docs/guides/analytics/hightouch.mdx @@ -0,0 +1,219 @@ +--- +title: 'Hightouch' +description: 'Learn how to set up Hightouch as a data source for Novu using the HTTP Request destination. Sync warehouse data to create subscribers and trigger notification workflows in Novu.' +--- + + +This guide demonstrates how to use Hightouch's HTTP Request destination to sync data from your warehouse into Novu. You'll learn how to: +- Create a reusable HTTP Request destination that points at the Novu API +- Sync rows from a Hightouch model into Novu subscribers +- Trigger notification workflows in Novu from a Hightouch events model +- Configure retries and rate limits for reliable delivery + +By the end, you'll have a working integration that creates subscribers and triggers notification workflows in Novu based on the data in your warehouse. + + + Before you start, ensure you have: + - A **Hightouch account** with a connected **source** (a warehouse or database such as Snowflake, BigQuery, or Postgres) and permission to create destinations and syncs + - At least one **model** in Hightouch that returns the rows you want to sync (for example, a table of users) + - A **Novu account** with an **API key** (find this in your Novu dashboard under **Settings** > **API Keys**) + + + + Hightouch is a reverse ETL platform. Instead of running code on each event like [Segment Destination Functions](/guides/analytics/segment), Hightouch queries a model on a schedule and sends an HTTP request for each row that is added, changed, or removed. You map those row changes to Novu API calls. + + + + + ## Create the HTTP Request destination + + Create one destination per service and reuse it across syncs. + + 1. Go to the **Destinations** overview page and click **Add destination** + 2. Select **HTTP Request** and click **Continue** + 3. Enter the **Base URL** for the Novu API: + - US (default): `https://api.novu.co` + - EU: `https://eu.api.novu.co` + 4. Under **HTTP headers**, add the following headers. Mark the `Authorization` value as **Secret** so it is encrypted and hidden in the UI: + + | Header | Value | + | --- | --- | + | `Authorization` | `ApiKey YOUR_NOVU_API_KEY` | + | `Content-Type` | `application/json` | + + 5. Leave the certificate options off unless your setup requires them, then click **Continue** + 6. Give the destination a descriptive name, such as `HTTP Request - Novu API`, and save + + + The base URL is the static part of the endpoint. You add the specific path (for example, `/v2/subscribers`) later in each sync, so a single Novu destination can power both the subscriber sync and the workflow trigger sync. + + + + + ## Sync a model into Novu subscribers + + This sync keeps Novu subscribers in step with the users in your warehouse. Use one request shape for both new and updated rows: `POST /v2/subscribers` creates a subscriber when the `subscriberId` is new and updates it when it already exists. + + Assume your model returns one row per user with columns like this: + + | `subscriber_id` | `first_name` | `last_name` | `email` | `phone` | + | --- | --- | --- | --- | --- | + | `97980cfea0067` | Peter | Gibbons | `peter@example.com` | +14158675309 | + + 1. Go to the **Syncs** overview page and click **Add sync** + 2. Select your users **model** and the **Novu** HTTP Request destination + 3. Under **request triggers**, enable **Rows added** and **Rows changed** + 4. Configure **both** triggers the same way: + + - **HTTP method**: `POST` + - **URL**: `/v2/subscribers` + - **Payload type**: JSON + - **Define JSON payload**: + + ```liquid + { + "subscriberId": "{{ row.subscriber_id }}", + "firstName": "{{ row.first_name }}", + "lastName": "{{ row.last_name }}", + "email": "{{ row.email }}", + "phone": "{{ row.phone }}" + } + ``` + + + + - Map `subscriberId` to a stable warehouse column that never changes. Novu uses it as the recipient identifier. + - `POST /v2/subscribers` is idempotent for a given `subscriberId`: the first request creates the subscriber, later requests update the same record. + - Wrap every string value in double quotes inside the JSON payload. Leave numeric or boolean values unquoted. + - Use Hightouch's **Preview** tab to confirm the rendered body before you save. + + + + If you only want to send changed fields and not a full profile, configure **Rows changed** as: + + - **HTTP method**: `PATCH` + - **URL**: `/v2/subscribers/{{ row.subscriber_id }}` + - Body with only the fields you want to update + + Prefer the shared `POST` upsert path above unless you specifically need partial updates. `PATCH` returns 404 if the subscriber does not exist yet. + + + + + + ## Trigger workflows from an events model + + To trigger a Novu workflow, sync a model that returns one row per event you want to notify on. Keep this separate from the subscriber sync so you can schedule and backfill them independently. + + Novu can [create a subscriber just in time](/platform/concepts/subscribers#just-in-time) when a workflow is triggered. A prior subscriber sync is useful for preferences and enrichment, but it is not required for triggers to succeed. + + A simple events model returns the target subscriber, the workflow to run, and the fields you need in the payload: + + | `subscriber_id` | `workflow_id` | `plan` | `account_type` | + | --- | --- | --- | --- | + | `97980cfea0067` | `welcome` | `Pro Annual` | `Facebook` | + + 1. Create a new sync from your events **model** to the **Novu** destination + 2. Under **request triggers**, enable **Rows added** only, so each new event fires once + 3. Configure the trigger: + + - **HTTP method**: `POST` + - **URL**: `/v1/events/trigger` + - **Payload type**: JSON + - **Define JSON payload**: + + ```liquid + { + "name": "{{ row.workflow_id }}", + "to": { + "subscriberId": "{{ row.subscriber_id }}" + }, + "payload": { + "plan": "{{ row.plan }}", + "accountType": "{{ row.account_type }}" + } + } + ``` + + + + - `name` is the workflow identifier in Novu. Store it in a column so one sync can drive many workflows, or hard-code a static value like `"welcome"` if the model only feeds one workflow. + - `to.subscriberId` addresses the recipient. You can also pass profile fields such as `email` or `firstName` inside `to` so Novu upserts them on trigger. + - Build `payload` from typed columns in the Liquid template. Prefer this over injecting a pre-serialized JSON string from the warehouse, which can escape incorrectly when the column is stored as text. + + + + + Enable only the **Rows added** trigger for workflow sends. Enabling **Rows changed** or **Rows removed** on an events model can trigger the same notification more than once. + + + + + ## Configure reliability and run the sync + + Before your first run, set rate limits and error handling so you stay within Novu's limits and recover cleanly from transient failures. + + 1. **Rate limiting and concurrency**: Hightouch defaults to 1000 requests per second. Novu's Free plan allows **20 RPS** for subscriber and configuration endpoints and **60 RPS** for event triggers. Higher plans raise those caps. See [Rate limiting](/api-reference/rate-limiting). Start below your plan's limit (for example, 10 to 20 RPS for subscriber syncs and 30 to 60 RPS for triggers on Free), then raise the limit if your plan allows it. + 2. **Error handling**: Hightouch treats any `400` or `500` level response as an error and can retry until the request succeeds. Retrying on the next sync run is appropriate for **429** and **5xx** responses. Permanent client errors such as **400**, **401**, **404**, and **422** usually mean a bad payload, key, or template. Fix those with the live debugger and alerts rather than relying on endless retries. + 3. **Initial sync behavior**: decide how existing rows are handled on the first run. For a subscriber backfill, sync all rows. For workflow triggers, skip existing rows so you do not notify users about historical events. + 4. Set a **schedule** or run the sync manually, then click **Run**. + + + + ## Verify the integration + + + + After the subscriber sync completes, open the **Subscribers** list in your Novu dashboard and confirm the rows appear with the expected `subscriberId`, name, email, and phone. + + + + After the events sync completes, open the **Activity Feed** in your Novu dashboard and confirm the workflow ran for the expected subscriber with the payload from your model. + + + + Use Hightouch's **live debugger** on the sync run to inspect the exact request and response for each row, which makes it easy to spot payload or authentication issues. + + + + + + - **401 Unauthorized**: Check the `Authorization` header on the destination. It must be `ApiKey YOUR_NOVU_API_KEY`, and the key must match your Novu environment (Development or Production) and region. + - **Workflow not triggering**: Confirm the value in `name` matches an existing workflow identifier in Novu, the workflow is active, and you are using an API key from the same environment. + - **Invalid JSON payload**: Make sure every string is wrapped in double quotes. Use Hightouch's payload **Preview** against a sample row before running the sync. + - **429 Too Many Requests**: Lower the rate limit and concurrency in the sync configuration to stay within your [plan limits](/api-reference/rate-limiting). + + + + - **One destination, many syncs**: The Novu destination holds the base URL and credentials. Add a new sync for each endpoint you need rather than creating a second destination. + - **Batching workflow triggers**: `POST /v1/events/trigger` expects a single event. For batches, use `POST /v1/events/trigger/bulk` with an `events` array. Bulk requests cost 100 rate-limit tokens each. Example Liquid body when batching is enabled: + + ```liquid + { + "events": [ + {% for row in rows %} + { + "name": "{{ row.workflow_id }}", + "to": { + "subscriberId": "{{ row.subscriber_id }}" + }, + "payload": { + "plan": "{{ row.plan }}", + "accountType": "{{ row.account_type }}" + } + }{% unless forloop.last %},{% endunless %} + {% endfor %} + ] + } + ``` + + - **Region**: EU accounts must use `https://eu.api.novu.co`. A US API key will not authenticate against the EU host, and the reverse is also true. + + + +With this setup, changes in your warehouse flow into Novu on every sync, keeping subscribers current and triggering notification workflows from the data you already trust. + +## Related guides + +- [Segment Destination Functions](/guides/analytics/segment) +- [Rate limiting](/api-reference/rate-limiting) diff --git a/docs/guides/webhooks/segment.mdx b/docs/guides/analytics/segment.mdx similarity index 55% rename from docs/guides/webhooks/segment.mdx rename to docs/guides/analytics/segment.mdx index 94e4e37276c..d33b6dab703 100644 --- a/docs/guides/webhooks/segment.mdx +++ b/docs/guides/analytics/segment.mdx @@ -15,7 +15,7 @@ By the end, you'll have a working integration that creates subscribers and trigg Before you start, ensure you have: - A **Segment account** with access to **Functions** (check your workspace permissions) - - A **Novu account** with an **API key** (find this in your Novu dashboard under Settings > API Keys) + - A **Novu account** with an **API key** (find this in your Novu dashboard under **Settings** > **API Keys**) @@ -25,7 +25,7 @@ By the end, you'll have a working integration that creates subscribers and trigg 1. Log in to your Segment account 2. Navigate to **Connections** > **Functions** in the left sidebar 3. Click **New Function** and select **Destination** - 4. Name your function (e.g., Novu Destination) and click **Create Function** + 4. Name your function (for example, Novu Destination) and click **Create Function** @@ -38,52 +38,59 @@ By the end, you'll have a working integration that creates subscribers and trigg Paste the following complete code into the Segment Function editor: ```jsx + // US: https://api.novu.co | EU: https://eu.api.novu.co + const NOVU_API_BASE_URL = 'https://api.novu.co'; + /** - * Handles identify events: Creates or updates a subscriber in Novu - * @param {SegmentIdentifyEvent} event - The Segment identify event - * @param {FunctionSettings} settings - Function settings including API key + * Posts to the Novu API. Retries on 5xx and 429. Fails fast on other 4xx responses. */ - async function onIdentify(event, settings) { - const endpoint = 'https://api.novu.co/v2/subscribers'; - const apiKey = settings.apiKey; - + async function novuRequest(path, apiKey, body) { if (!apiKey) throw new Error('Novu API key is missing in settings'); - if (!event.userId) throw new Error('userId is required in identify event'); - - const subscriberData = { - subscriberId: event.userId, - firstName: event.traits?.firstName || null, - lastName: event.traits?.lastName || null, - email: event.traits?.email || null, - phone: event.traits?.phone || null, - avatar: event.traits?.avatar || null, - }; + let response; try { - const response = await fetch(endpoint, { + response = await fetch(`${NOVU_API_BASE_URL}${path}`, { method: 'POST', headers: { 'Authorization': `ApiKey ${apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify(subscriberData) + body: JSON.stringify(body) }); + } catch (error) { + throw new RetryError(error.message); + } - const responseBody = await response.json(); - if (!response.ok) { - if (response.status >= 500 || response.status === 429) { - throw new RetryError(`Server error: ${response.status}`); - } - throw new Error(`API error: ${response.status} - ${responseBody.message || 'Unknown error'}`); + const responseBody = await response.json().catch(() => ({})); + if (!response.ok) { + if (response.status >= 500 || response.status === 429) { + throw new RetryError(`Server error: ${response.status}`); } - } catch (error) { - throw error instanceof RetryError ? error : new RetryError(error.message); + throw new Error(`API error: ${response.status} - ${responseBody.message || 'Unknown error'}`); } } + /** + * Handles identify events: Creates or updates a subscriber in Novu + * @param {SegmentIdentifyEvent} event - The Segment identify event + * @param {FunctionSettings} settings - Function settings including API key + */ + async function onIdentify(event, settings) { + if (!event.userId) throw new Error('userId is required in identify event'); + + await novuRequest('/v2/subscribers', settings.apiKey, { + subscriberId: event.userId, + firstName: event.traits?.firstName, + lastName: event.traits?.lastName, + email: event.traits?.email, + phone: event.traits?.phone, + avatar: event.traits?.avatar, + }); + } + // Mapping of Segment track events to Novu workflows const EVENT_TO_WORKFLOW_MAPPINGS = { - 'User Registered': 'welcome' + 'User Registered': 'welcome', // Add more mappings: 'Event Name': 'novu-workflow-name' }; @@ -93,59 +100,34 @@ By the end, you'll have a working integration that creates subscribers and trigg * @param {FunctionSettings} settings - Function settings including API key */ async function onTrack(event, settings) { - const endpoint = 'https://api.novu.co/v1/events/trigger'; - const apiKey = settings.apiKey; - - if (!apiKey) throw new Error('Novu API key is missing in settings'); if (!event.userId) throw new Error('userId is required in track event'); const workflow = EVENT_TO_WORKFLOW_MAPPINGS[event.event]; if (!workflow) throw new Error(`No workflow mapped for event: ${event.event}`); - const triggerEvent = { + await novuRequest('/v1/events/trigger', settings.apiKey, { name: workflow, to: { subscriberId: event.userId }, payload: event.properties || {} - }; - - try { - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Authorization': `ApiKey ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(triggerEvent) - }); - - const responseBody = await response.json(); - if (!response.ok) { - if (response.status >= 500 || response.status === 429) { - throw new RetryError(`Server error: ${response.status}`); - } - throw new Error(`API error: ${response.status} - ${responseBody.message || 'Unknown error'}`); - } - } catch (error) { - throw error instanceof RetryError ? error : new RetryError(error.message); - } + }); } ``` + - **`novuRequest`**: Shared helper for Novu API calls. Retries on server errors (`5xx`) and rate limits (`429`). Fails fast on other client errors so bad payloads are not retried forever. - **`onIdentify`**: - - Maps Segment traits (e.g., firstName, lastName, email) to Novu subscriber fields - - Uses Novu's `/v2/subscribers` endpoint - - Creates or updates subscribers (Novu's API is idempotent for existing `subscriberIds`) + - Maps Segment traits (`firstName`, `lastName`, `email`, `phone`, `avatar`) to Novu subscriber fields + - Uses `POST /v2/subscribers`, which creates a subscriber or updates the existing one when `subscriberId` matches - **`onTrack`**: - Maps Segment `track` events to Novu workflows using `EVENT_TO_WORKFLOW_MAPPINGS` - - Sends the event properties as the payload to trigger a workflow via `/v1/events/trigger` - - **Error Handling**: Retries on server errors (5xx) or rate limits (429), fails fast on other errors + - Sends the event properties as the payload via `POST /v1/events/trigger` + - Novu can [create a subscriber just in time](/platform/concepts/subscribers#just-in-time) from `to.subscriberId`, so a prior `identify` is useful for enrichment but not required for the trigger to succeed - Update `EVENT_TO_WORKFLOW_MAPPINGS` with your Segment event names and corresponding Novu workflow names. + Update `EVENT_TO_WORKFLOW_MAPPINGS` with your Segment event names and corresponding Novu workflow identifiers. For EU accounts, set `NOVU_API_BASE_URL` to `https://eu.api.novu.co`. @@ -159,10 +141,10 @@ By the end, you'll have a working integration that creates subscribers and trigg ## Connect the Function to a Source - 1. Go to **Connections** > Select your **Source** (e.g., website, app) + 1. Go to **Connections** > Select your **Source** (for example, website or app) 2. In the **Destinations** tab, click **Add Destination** 3. Choose your **Novu Destination Function** from the list - 4. Click **Connect**. When prompted, enter your **Novu API key** in the apiKey field + 4. Click **Connect**. When prompted, enter your **Novu API key** in the `apiKey` field 5. Save the configuration @@ -177,16 +159,16 @@ By the end, you'll have a working integration that creates subscribers and trigg ```json { "type": "identify", + "userId": "97980cfea0067", "traits": { - "name": "Peter Gibbons", + "firstName": "Peter", + "lastName": "Gibbons", "email": "peter@example.com", - "plan": "premium", - "logins": 5 - }, - "userId": "97980cfea0067" + "phone": "+14158675309" + } } ``` - Check Novu's **Subscribers** list to confirm the subscriber appears. + Check Novu's **Subscribers** list to confirm the subscriber appears with the mapped fields. @@ -195,13 +177,14 @@ By the end, you'll have a working integration that creates subscribers and trigg { "type": "track", "event": "User Registered", + "userId": "97980cfea0067", "properties": { "plan": "Pro Annual", - "accountType" : "Facebook" + "accountType": "Facebook" } } ``` - Verify the `welcome` workflow triggers in Novu's activity feed. + Verify the `welcome` workflow triggers in Novu's **Activity Feed**. @@ -211,16 +194,21 @@ By the end, you'll have a working integration that creates subscribers and trigg - - **401 Unauthorized**: Double-check your Novu API key in the function settings - - **Subscriber Not Created**: Ensure userId is included in the identify event - - **Workflow Not Triggering**: Confirm the event name matches a key in EVENT_TO_WORKFLOW_MAPPINGS and the workflow exists in Novu - - **API Errors**: Check Segment's logs for detailed error messages + - **401 Unauthorized**: Double-check your Novu API key in the function settings. The key must match your Novu environment (Development or Production) and region (US vs EU). + - **Subscriber not created**: Ensure `userId` is included in the `identify` event. Traits must use the field names your function maps (`firstName`, `lastName`, `email`, and so on). + - **Workflow not triggering**: Confirm the event name matches a key in `EVENT_TO_WORKFLOW_MAPPINGS`, the workflow exists and is active in Novu, and the track event includes `userId`. + - **429 Too Many Requests**: Segment will retry when the function throws `RetryError`. If you hit limits often, reduce event volume or upgrade your Novu plan. See [Rate limiting](/api-reference/rate-limiting). - - **Subscriber Updates**: Novu's /v1/subscribers endpoint updates existing subscribers if the subscriberId matches, keeping data current with each identify event - - **Expanding Functionality**: Add more event types (e.g., group, page) by defining additional handlers like onGroup in the code + - **Subscriber updates**: `POST /v2/subscribers` updates an existing subscriber when `subscriberId` matches, so each `identify` keeps the profile current. + - **Expanding functionality**: Add more event types (for example, `group` or `page`) by defining additional handlers such as `onGroup` in the function. -With this setup, your Segment events will seamlessly flow into Novu, enabling powerful notification workflows tailored to your users' actions. +With this setup, Segment `identify` and `track` events map into Novu subscribers and workflow triggers. + +## Related guides + +- [Hightouch HTTP Request destination](/guides/analytics/hightouch) +- [Rate limiting](/api-reference/rate-limiting) diff --git a/docs/guides/webhooks/auth0.mdx b/docs/guides/webhooks/auth0.mdx index fea62e33afc..cfdb76dbbbe 100644 --- a/docs/guides/webhooks/auth0.mdx +++ b/docs/guides/webhooks/auth0.mdx @@ -42,7 +42,8 @@ import { Novu } from '@novu/api'; const novu = new Novu({ secretKey: process.env.NOVU_SECRET_KEY }); export async function handleAuth0Event(event: Auth0LogEvent) { - if (event.type === 's' && event.description === 'Success Signup') { + // Auth0 Success Signup is type "ss". Type "s" is Success Login. + if (event.type === 'ss') { await novu.trigger({ workflowId: 'welcome-email', to: { subscriberId: event.user_id, email: event.user_name }, @@ -59,7 +60,8 @@ import novu_py from novu_py import Novu def handle_auth0_event(event): - if event["type"] == "s" and event["description"] == "Success Signup": + # Auth0 Success Signup is type "ss". Type "s" is Success Login. + if event["type"] == "ss": with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu: novu.trigger(trigger_event_request_dto=novu_py.TriggerEventRequestDto( workflow_id="welcome-email", @@ -79,7 +81,8 @@ import ( ) func handleAuth0Event(event Auth0LogEvent) error { - if event.Type == "s" && event.Description == "Success Signup" { + // Auth0 Success Signup is type "ss". Type "s" is Success Login. + if event.Type == "ss" { s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY"))) _, err := s.Trigger(context.Background(), components.TriggerEventRequestDto{ WorkflowID: "welcome-email", @@ -104,7 +107,8 @@ use novu\Models\Components; function handleAuth0Event(array $event): void { - if ($event['type'] === 's' && $event['description'] === 'Success Signup') { + // Auth0 Success Signup is type "ss". Type "s" is Success Login. + if ($event['type'] === 'ss') { $sdk = novu\Novu::builder() ->setSecurity('') ->build(); @@ -133,7 +137,8 @@ using System.Collections.Generic; async Task HandleAuth0Event(Auth0LogEvent auth0Event) { - if (auth0Event.Type == "s" && auth0Event.Description == "Success Signup") + // Auth0 Success Signup is type "ss". Type "s" is Success Login. + if (auth0Event.Type == "ss") { var sdk = new NovuSDK(secretKey: ""); @@ -158,7 +163,8 @@ import co.novu.models.components.*; import java.util.Map; void handleAuth0Event(Auth0LogEvent event) { - if ("s".equals(event.getType()) && "Success Signup".equals(event.getDescription())) { + // Auth0 Success Signup is type "ss". Type "s" is Success Login. + if ("ss".equals(event.getType())) { Novu novu = Novu.builder() .secretKey("") .build(); @@ -206,6 +212,9 @@ curl -X POST 'https://api.novu.co/v1/events/trigger' \ Auth0 Actions give you inline control during the auth flow. Log streams are better for post-event processing such as security alerts and analytics-driven notifications. + + Successful signup is type `ss`. Type `s` is a successful login. Match on `event.type` rather than free-text descriptions, which can vary. + Always verify signatures in your backend before triggering Novu workflows. Never expose your Novu secret key in client-side Auth0 Actions. diff --git a/docs/guides/webhooks/clerk.mdx b/docs/guides/webhooks/clerk.mdx index 5e9599fd4c5..8c52b8b5b4a 100644 --- a/docs/guides/webhooks/clerk.mdx +++ b/docs/guides/webhooks/clerk.mdx @@ -4,11 +4,11 @@ description: "Integrate Clerk webhooks with Novu notifications in a Next.js app. --- -You'll learn how to automatically trigger notification workflows when **any Clerk event** occurs, such as **user creation, email events, or password changes**. +You'll learn how to automatically trigger notification workflows when Clerk events occur, such as user creation, email events, or password changes. ## Overview -When specific events happen in Clerk (e.g., user signup, password changes, email verification), this integration will: +When specific events happen in Clerk (for example, user signup, password changes, or email verification), this integration will: 1. Receive the webhook event from Clerk. 2. Verify the webhook signature. @@ -34,7 +34,7 @@ Before proceeding, ensure you have: Run the following command to install the required packages: ``` -npm install svix @novu/api @clerk/nextjs +npm install @novu/api @clerk/nextjs ``` @@ -48,7 +48,7 @@ Add the following variables to your `.env.local` file: ``` NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... -CLERK_SIGNING_SECRET=whsec_... +CLERK_WEBHOOK_SIGNING_SECRET=whsec_... NOVU_SECRET_KEY=novu_secret_... ``` @@ -73,7 +73,7 @@ There are two common options: npx localtunnel 3000 ``` -2. Copy and save the generated **public URL** (e.g., `https://your-localtunnel-url.loca.lt`). +2. Copy and save the generated **public URL** (for example, `https://your-localtunnel-url.loca.lt`). Learn more about **localtunnel** [here](https://www.npmjs.com/package/localtunnel). @@ -94,7 +94,7 @@ For a more stable and configurable tunnel, use **ngrok**: ngrok http 3000 ``` -4. Copy and save the **Forwarding URL** (e.g., `https://your-ngrok-url.ngrok.io`). +4. Copy and save the **Forwarding URL** (for example, `https://your-ngrok-url.ngrok.io`). Learn more about **ngrok** [here](https://dashboard.ngrok.com/get-started/setup). @@ -115,10 +115,10 @@ Learn more about **ngrok** [here](https://dashboard.ngrok.com/get-started/setup) https://your-forwarding-URL/api/webhooks/clerk ``` -4. Subscribe to the **relevant Clerk events** (e.g., `user.created`, `email.created` etc.). +4. Subscribe to the **relevant Clerk events** (for example, `user.created`, `email.created`). - You can find the list of all supported Clerk events [here](https://clerk.com/docs/reference/webhooks/events), or proceed to the section which going over [Identify the Triggering Event(s).](#identify-the-triggering-events) + You can find the list of all supported Clerk events [here](https://clerk.com/docs/reference/webhooks/events), or continue to [Identify the Triggering Event(s)](#identify-the-triggering-events). 5. Click **Create** and keep the settings page open. @@ -133,25 +133,31 @@ Learn more about **ngrok** [here](https://dashboard.ngrok.com/get-started/setup) 2. Add it to your `.env.local` file: ``` -CLERK_SIGNING_SECRET=your_signing_secret_here +CLERK_WEBHOOK_SIGNING_SECRET=your_signing_secret_here ``` -## Make Webhook Route Public +## Make the webhook route public -Ensure the webhook route is public by updating `middleware.ts` : +Incoming Clerk webhooks are not signed-in sessions. If you protect routes with Clerk middleware, exclude the webhook path: -```jsx -import { clerkMiddleware } from '@clerk/nextjs/server'; +```tsx +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; -export default clerkMiddleware({ - publicRoutes: ['/api/webhooks'], +const isPublicRoute = createRouteMatcher(['/api/webhooks(.*)']); + +export default clerkMiddleware(async (auth, req) => { + if (!isPublicRoute(req)) { + await auth.protect(); + } }); ``` +By default, `clerkMiddleware()` does not protect any routes. This step only matters if you have already added auth checks that would block `/api/webhooks`. + @@ -172,387 +178,123 @@ Create `app/api/webhooks/clerk/route.ts`: -The following snippet is the complete code of how to create a webhook endpoint for Clerk in Next.js: +The following snippet is the complete webhook route for Clerk in Next.js: -```jsx -import { Webhook } from 'svix' -import { headers } from 'next/headers' -import { WebhookEvent, UserJSON } from '@clerk/nextjs/server' +```tsx +import { verifyWebhook } from '@clerk/nextjs/webhooks' +import type { WebhookEvent } from '@clerk/nextjs/server' import { triggerWorkflow } from '@/app/utils/novu' -// Single source of truth for all supported Clerk events and their corresponding Novu workflows +// Map Clerk event types (and email.created slugs) to Novu workflow identifiers const EVENT_TO_WORKFLOW_MAPPINGS = { - // Session events - 'session.created': 'recent-login-v2', - - // User events - 'user.created': 'user-created', - - // Email events - 'email.created': { - 'magic_link_sign_in': 'auth-magic-link-login', - 'magic_link_sign_up': 'auth-magic-link-registration', - 'magic_link_user_profile': 'profile-magic-link-update', - 'organization_invitation': 'organization-invitation-v2', - 'organization_invitation_accepted': 'org-member-joined', - 'passkey_added': 'security-passkey-created', - 'passkey_removed': 'security-passkey-deleted', - 'password_changed': 'security-password-updated', - 'password_removed': 'security-password-deleted', - 'primary_email_address_changed': 'profile-email-updated', - 'reset_password_code': 'reset-password-code-v2', - 'verification_code': 'verification-code-v2', - 'waitlist_confirmation': 'waitlist-signup-confirmed', - 'waitlist_invitation': 'waitlist-access-granted', - 'invitation': 'user-invitation' - } -} as const; + 'session.created': 'session-created', + 'user.created': 'user-created', + 'email.created': { + magic_link_sign_in: 'auth-magic-link-login', + magic_link_sign_up: 'auth-magic-link-registration', + magic_link_user_profile: 'profile-magic-link-update', + organization_invitation: 'organization-invitation', + organization_invitation_accepted: 'org-member-joined', + passkey_added: 'security-passkey-created', + passkey_removed: 'security-passkey-deleted', + password_changed: 'security-password-updated', + password_removed: 'security-password-deleted', + primary_email_address_changed: 'profile-email-updated', + reset_password_code: 'reset-password-code', + verification_code: 'verification-code', + waitlist_confirmation: 'waitlist-signup-confirmed', + waitlist_invitation: 'waitlist-access-granted', + invitation: 'user-invitation', + }, +} as const export async function POST(request: Request) { - try { - const SIGNING_SECRET = process.env.SIGNING_SECRET - if (!SIGNING_SECRET) { - throw new Error('Please add SIGNING_SECRET from Clerk Dashboard to .env') - } - - const webhook = new Webhook(SIGNING_SECRET) - const headerPayload = await headers() - const validatedHeaders = validateHeaders(headerPayload) - - const payload = await request.json() - const body = JSON.stringify(payload) - - const event = await verifyWebhook(webhook, body, { - 'svix-id': validatedHeaders.svix_id, - 'svix-timestamp': validatedHeaders.svix_timestamp, - 'svix-signature': validatedHeaders.svix_signature, - }) - - await handleWebhookEvent(event) + try { + // verifyWebhook reads CLERK_WEBHOOK_SIGNING_SECRET and validates the raw body + const event = await verifyWebhook(request) + await handleWebhookEvent(event as WebhookEvent) - return new Response('Webhook received', { status: 200 }) - } catch (error) { - console.error('Webhook processing error:', error) - return new Response(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`, { status: 400 }) - } + return new Response('Webhook received', { status: 200 }) + } catch (error) { + console.error('Webhook processing error:', error) + return new Response( + `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, + { status: 400 } + ) + } } -const handleWebhookEvent = async (event: WebhookEvent) => { - const workflow = await workflowBuilder(event) - if (!workflow) { - console.log(`Unsupported event type: ${event.type}`) - return - } +async function handleWebhookEvent(event: WebhookEvent) { + const workflow = workflowBuilder(event) + if (!workflow) { + console.log(`Unsupported event type: ${event.type}`) + return + } - const subscriber = await subscriberBuilder(event) - const payload = await payloadBuilder(event) + const subscriber = subscriberBuilder(event) + const payload = payloadBuilder(event) - await triggerWorkflow(workflow, subscriber, payload) + await triggerWorkflow(workflow, subscriber, payload) } -async function workflowBuilder(event: WebhookEvent): Promise { - if (!EVENT_TO_WORKFLOW_MAPPINGS[event.type as keyof typeof EVENT_TO_WORKFLOW_MAPPINGS]) { - return undefined; - } +function workflowBuilder(event: WebhookEvent): string | undefined { + if (!(event.type in EVENT_TO_WORKFLOW_MAPPINGS)) { + return undefined + } - if (event.type === 'email.created' && event.data.slug) { - const emailMappings = EVENT_TO_WORKFLOW_MAPPINGS['email.created']; - const emailSlug = event.data.slug as keyof typeof emailMappings; - return emailMappings[emailSlug] || `email-${String(emailSlug).replace(/_/g, '-')}`; + if (event.type === 'email.created') { + if (!('slug' in event.data) || !event.data.slug) { + return undefined } - return EVENT_TO_WORKFLOW_MAPPINGS[event.type as keyof typeof EVENT_TO_WORKFLOW_MAPPINGS] as string; -} - -async function subscriberBuilder(response: WebhookEvent) { - const userData = response.data as UserJSON; - - if (!userData.id) { - throw new Error('Missing subscriber ID from webhook data'); - } + const emailMappings = EVENT_TO_WORKFLOW_MAPPINGS['email.created'] + const emailSlug = event.data.slug as keyof typeof emailMappings - return { - subscriberId: (userData as any).user_id ?? userData.id, - firstName: userData.first_name ?? undefined, - lastName: userData.last_name ?? undefined, - email: (userData.email_addresses?.[0]?.email_address ?? (userData as any).to_email_address) ?? undefined, - phone: userData.phone_numbers?.[0]?.phone_number ?? undefined, - locale: 'en_US', - avatar: userData.image_url ?? undefined, - data: { - clerkUserId: (userData as any).user_id ?? userData.id, - username: userData.username ?? '', - }, - } -} + return emailMappings[emailSlug] + } -async function payloadBuilder(response: WebhookEvent) { - return response.data; + return EVENT_TO_WORKFLOW_MAPPINGS[event.type as keyof typeof EVENT_TO_WORKFLOW_MAPPINGS] as string } -const validateHeaders = (headerPayload: Headers) => { - const svix_id = headerPayload.get('svix-id') - const svix_timestamp = headerPayload.get('svix-timestamp') - const svix_signature = headerPayload.get('svix-signature') +function subscriberBuilder(event: WebhookEvent) { + const data = event.data as Record + const subscriberId = data.user_id ?? data.id - if (!svix_id || !svix_timestamp || !svix_signature) { - throw new Error('Missing Svix headers') - } + if (!subscriberId) { + throw new Error('Missing subscriber ID from webhook data') + } - return { svix_id, svix_timestamp, svix_signature } + return { + subscriberId, + firstName: data.first_name ?? undefined, + lastName: data.last_name ?? undefined, + email: + data.email_addresses?.[0]?.email_address ?? + data.to_email_address ?? + undefined, + phone: data.phone_numbers?.[0]?.phone_number ?? undefined, + avatar: data.image_url ?? undefined, + data: { + clerkUserId: subscriberId, + username: data.username ?? '', + }, + } } -const verifyWebhook = async (webhook: Webhook, body: string, headers: any): Promise => { - try { - return webhook.verify(body, headers) as WebhookEvent - } catch (err) { - console.error('Error: Could not verify webhook:', err) - throw new Error('Verification error') - } +function payloadBuilder(event: WebhookEvent) { + return event.data } ``` - - ---- - -**Imports and Dependencies** - -```jsx -import { Webhook } from 'svix' -import { headers } from 'next/headers' -import { WebhookEvent, UserJSON } from '@clerk/nextjs/server' -import { triggerWorkflow } from '@/app/utils/novu' -``` - -- `Webhook` from `svix`: This is a library used to verify the authenticity of incoming webhooks by checking their signatures. Webhooks often use signatures to ensure the payload hasn’t been tampered with. - -- `headers` from `next/headers`: A Next.js utility to access HTTP headers from the incoming request in the App Router. - -- `WebhookEvent` from `@clerk/nextjs/server`: A type definition for webhook events, likely provided by Clerk (a user authentication and management service). This ensures type safety when handling events. - -- `triggerWorkflow`: A custom function (imported from another file) that triggers a workflow. This is likely where notifications or other business logic is executed. - ---- - -**Event Mapping** - -```jsx -const EVENT_TO_WORKFLOW_MAPPINGS = { - - // Clerk webhook event type -> Novu workflowId - - // Session events - 'session.created': 'session-created', - - // User events - 'user.created': 'user-created', - - // Email events - 'email.created': { - 'magic_link_sign_in': 'auth-magic-link-login', - 'magic_link_sign_up': 'auth-magic-link-registration', - 'magic_link_user_profile': 'profile-magic-link-update', - 'organization_invitation': 'organization-invitation', - 'organization_invitation_accepted': 'org-member-joined', - 'passkey_added': 'security-passkey-created', - 'passkey_removed': 'security-passkey-deleted', - 'password_changed': 'security-password-updated', - 'password_removed': 'security-password-deleted', - 'primary_email_address_changed': 'profile-email-updated', - 'reset_password_code': 'reset-password-code', - 'verification_code': 'verification-code', - 'waitlist_confirmation': 'waitlist-signup-confirmed', - 'waitlist_invitation': 'waitlist-access-granted', - 'invitation': 'user-invitation' - } -} as const; -``` - -This mapping defines how Clerk webhook events are associated with Novu workflows. - ---- - -**Main Entry Point: `POST` Handler** - -```jsx -export async function POST(request: Request) { - try { - const SIGNING_SECRET = process.env.SIGNING_SECRET - if (!SIGNING_SECRET) { - throw new Error('Please add SIGNING_SECRET from Clerk Dashboard to .env') - } - - const webhook = new Webhook(SIGNING_SECRET) - const headerPayload = await headers() - const validatedHeaders = validateHeaders(headerPayload) - - const payload = await request.json() - const body = JSON.stringify(payload) - - const event = await verifyWebhook(webhook, body, { - 'svix-id': validatedHeaders.svix_id, - 'svix-timestamp': validatedHeaders.svix_timestamp, - 'svix-signature': validatedHeaders.svix_signature, - }) - - await handleWebhookEvent(event) - - return new Response('Webhook received', { status: 200 }) - } catch (error) { - console.error('Webhook processing error:', error) - return new Response(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`, { status: 400 }) - } -} -``` - -This is the main function that handles incoming HTTP POST requests (webhook events). - ---- - -**Handling the Webhook Event: `handleWebhookEvent`** - -```jsx -const handleWebhookEvent = async (event: WebhookEvent) => { - const workflow = await workflowBuilder(event) - if (!workflow) { - console.log(`Unsupported event type: ${event.type}`) - return - } - - const subscriber = await subscriberBuilder(event) - const payload = await payloadBuilder(event) - - await triggerWorkflow(workflow, subscriber, payload) -} -``` - -This function processes the verified webhook event. - ---- - -**Identify the WorkflowID based on the event type: `workflowBuilder`** - -```jsx -async function workflowBuilder(event: WebhookEvent): Promise { - if (!EVENT_TO_WORKFLOW_MAPPINGS[event.type as keyof typeof EVENT_TO_WORKFLOW_MAPPINGS]) { - return undefined; - } - - if (event.type === 'email.created' && event.data.slug) { - const emailMappings = EVENT_TO_WORKFLOW_MAPPINGS['email.created']; - const emailSlug = event.data.slug as keyof typeof emailMappings; - return emailMappings[emailSlug] || `email-${String(emailSlug).replace(/_/g, '-')}`; - } - - return EVENT_TO_WORKFLOW_MAPPINGS[event.type as keyof typeof EVENT_TO_WORKFLOW_MAPPINGS] as string; -} -``` - -This function determines the workflow ID by mapping the Clerk webhook event type to the Novu workflow ID. - ---- - -**Building the Subscriber: `subscriberBuilder`** - -```jsx -async function subscriberBuilder(response: WebhookEvent) { - const userData = response.data as UserJSON; - - if (!userData.id) { - throw new Error('Missing subscriber ID from webhook data'); - } - - return { - subscriberId: (userData as any).user_id ?? userData.id, - firstName: userData.first_name ?? undefined, - lastName: userData.last_name ?? undefined, - email: (userData.email_addresses?.[0]?.email_address ?? (userData as any).to_email_address) ?? undefined, - phone: userData.phone_numbers?.[0]?.phone_number ?? undefined, - locale: 'en_US', - avatar: userData.image_url ?? undefined, - data: { - clerkUserId: (userData as any).user_id ?? userData.id, - username: userData.username ?? '', - }, - } -} -``` - -This function builds the subscriber data based on the webhook event data. - ---- - -**Building the Payload: `payloadBuilder`** - -```jsx -async function payloadBuilder(response: WebhookEvent) { - return response.data; -} -``` - -This function constructs (extracts from the webhook event) the payload object data that will be used within workflow trigger call. - ---- - -**Validating the Headers: `validateHeaders`** - -```jsx -const validateHeaders = (headerPayload: Headers) => { - const svix_id = headerPayload.get('svix-id') - const svix_timestamp = headerPayload.get('svix-timestamp') - const svix_signature = headerPayload.get('svix-signature') - - if (!svix_id || !svix_timestamp || !svix_signature) { - throw new Error('Missing Svix headers') - } - - return { svix_id, svix_timestamp, svix_signature } -} -``` - -This function extracts and validates the Svix headers from the request. - ---- - -**Verifying the Webhook: `verifyWebhook`** - -```jsx + -const verifyWebhook = async (webhook: Webhook, body: string, headers: any): Promise => { - try { - return webhook.verify(body, headers) as WebhookEvent - } catch (err) { - console.error('Error: Could not verify webhook:', err) - throw new Error('Verification error') - } -} -``` - -This function verifies the authenticity of the webhook using the `svix` library. - ---- - -**How It All Fits Together** - -Here’s a high-level flow of how the code works: - -1. **Receive a Webhook:** - The `POST` handler receives an `HTTP POST` request with a webhook payload. - -2. **Validate and Verify:** - The `validateHeaders` function ensures the required Svix headers are present. The `verifyWebhook` function uses the `svix` library to verify the webhook’s authenticity. +- **`verifyWebhook`**: Clerk's helper validates the Svix signature using `CLERK_WEBHOOK_SIGNING_SECRET`. Pass the `Request` directly so the raw body is preserved. Do not call `request.json()` before verification. +- **`EVENT_TO_WORKFLOW_MAPPINGS`**: Maps Clerk event types to Novu workflow identifiers. For `email.created`, the nested map uses the email `slug` (for example, `password_changed`). +- **`subscriberBuilder`**: Builds the Novu `to` object. Prefer `user_id` when present (session and email events); fall back to `id` for `user.created`. +- **`triggerWorkflow`**: Calls your Novu helper with the workflow ID, subscriber, and event payload. -3. **Process the Event:** - The `handleWebhookEvent` function filters events and processes only `user.created` or `email.created` events. - - It calls helper functions (`workflowBuilder`, `subscriberBuilder`, `payloadBuilder`) to construct the necessary data. - -4. **Trigger a Workflow:** - The `triggerWorkflow` function is called with the constructed data, executing the desired business logic (e.g., sending notifications). - ---- +Update the mapping values to match the workflow identifiers you create in the Novu dashboard. @@ -580,143 +322,23 @@ Create `app/utils/novu.ts` : - - ```typescript import { Novu } from '@novu/api'; const novu = new Novu({ - secretKey: process.env['NOVU_SECRET_KEY']!, + secretKey: process.env.NOVU_SECRET_KEY!, }); -export async function triggerWorkflow(workflowId: string, subscriber: object, payload: object) { - try { - await novu.trigger({ workflowId, to: subscriber, payload }); - return new Response('Notification triggered', { status: 200 }); - } catch (error) { - return new Response('Error triggering notification', { status: 500 }); - } -} -``` - - -```python -import os -import novu_py -from novu_py import Novu - -def trigger_workflow(workflow_id: str, subscriber: dict, payload: dict): - with Novu(secret_key=os.getenv("NOVU_SECRET_KEY", "")) as novu: - novu.trigger(trigger_event_request_dto=novu_py.TriggerEventRequestDto( - workflow_id=workflow_id, - to=subscriber, - payload=payload, - )) -``` - - -```go -import ( - "context" - "os" - - novugo "github.com/novuhq/novu-go" - "github.com/novuhq/novu-go/models/components" -) - -func triggerWorkflow(workflowID string, subscriber components.SubscriberPayloadDto, payload map[string]any) error { - s := novugo.New(novugo.WithSecurity(os.Getenv("NOVU_SECRET_KEY"))) - _, err := s.Trigger(context.Background(), components.TriggerEventRequestDto{ - WorkflowID: workflowID, - To: components.CreateToSubscriberPayloadDto(subscriber), - Payload: payload, - }, nil) - return err -} -``` - - -```php -use novu; -use novu\Models\Components; - -function triggerWorkflow(string $workflowId, array $subscriber, array $payload): void -{ - $sdk = novu\Novu::builder() - ->setSecurity('') - ->build(); - - $sdk->trigger( - triggerEventRequestDto: new Components\TriggerEventRequestDto( - workflowId: $workflowId, - to: new Components\SubscriberPayloadDto( - subscriberId: $subscriber['subscriberId'], - email: $subscriber['email'] ?? null, - firstName: $subscriber['firstName'] ?? null, - lastName: $subscriber['lastName'] ?? null, - ), - payload: $payload, - ), - ); -} -``` - - -```csharp -using Novu; -using Novu.Models.Components; -using System.Collections.Generic; - -var novu = new NovuSDK(secretKey: ""); - -async Task TriggerWorkflow(string workflowId, SubscriberPayloadDto subscriber, Dictionary payload) -{ - await novu.TriggerAsync(triggerEventRequestDto: new TriggerEventRequestDto() { - WorkflowId = workflowId, - To = To.CreateSubscriberPayloadDto(subscriber), - Payload = payload, - }); -} -``` - - -```java -import co.novu.Novu; -import co.novu.models.components.*; -import java.util.Map; - -Novu novu = Novu.builder() - .secretKey("") - .build(); - -void triggerWorkflow(String workflowId, SubscriberPayloadDto subscriber, Map payload) { - novu.trigger() - .body(TriggerEventRequestDto.builder() - .workflowId(workflowId) - .to(To2.of(subscriber)) - .payload(payload) - .build()) - .call(); +export async function triggerWorkflow( + workflowId: string, + subscriber: Record, + payload: Record +) { + await novu.trigger({ workflowId, to: subscriber, payload }); } ``` - - -```bash -curl -X POST 'https://api.novu.co/v1/events/trigger' \ --H 'Content-Type: application/json' \ --H 'Authorization: ApiKey ' \ --d '{ - "name": "user-created", - "to": { - "subscriberId": "user_123", - "email": "user@example.com", - "firstName": "Jane" - }, - "payload": {} -}' -``` - - + +This helper is for the Next.js route above. For other languages, see the [server SDKs](/platform/sdks#server-side-sdks). @@ -724,11 +346,11 @@ curl -X POST 'https://api.novu.co/v1/events/trigger' \ ## Add or create Novu workflows in your Novu dashboard -In Novu, a webhook event - such as a user being created or updated - can trigger one or more workflows, depending on how you want to handle these events in your application. +In Novu, a Clerk webhook event can trigger one or more workflows, depending on how you want to handle those events. -A workflow defines a sequence of actions (e.g., sending notifications, updating records) that execute when triggered by a webhook. +A workflow defines a sequence of actions (for example, sending notifications) that run when triggered by a webhook. -The Novu dashboard allows you to either create a custom workflow from scratch or choose from pre-built templates to streamline the process. +The Novu dashboard lets you create a custom workflow from scratch or start from a template. **Steps to Create a Workflow** @@ -736,9 +358,8 @@ Follow these steps to set up your workflow(s) in the Novu dashboard: ### Identify the Triggering Event(s) - Determine which webhook events will activate your workflow (e.g., "user created," "user updated"). - - Check your webhook configuration to understand the event data being sent. + Determine which Clerk webhook events will activate your workflow (for example, `user.created` or `email.created`). + Create Novu workflow identifiers that match the values in `EVENT_TO_WORKFLOW_MAPPINGS`. @@ -751,69 +372,28 @@ Follow these steps to set up your workflow(s) in the Novu dashboard: - The payload of a webhook is a JSON object that contains the following properties: - - - `data`: contains the actual payload sent by Clerk. - The payload can be a different object depending on the event type. - - For example, for `user.*` events, the payload will always be the [User object](https://clerk.com/docs/references/javascript/user). - - - `object`: always set to `event`. - - - `type`: the type of event that triggered the webhook. - - - `timestamp`: timestamp in milliseconds of when the event occurred. - - - `instance_id`: the identifier of your Clerk instance. - - The following example shows the payload of a `user.created` event: + Clerk events are JSON objects with `type`, `data`, `timestamp`, and `instance_id`. For `user.*` events, `data` is a [User object](https://clerk.com/docs/references/javascript/user). Your handler maps `data.id` (or `data.user_id` when present) to Novu's `subscriberId`. + + Shortened `user.created` example: ```json { - "data": { - "birthday": "", - "created_at": 1654012591514, - "email_addresses": [ - { - "email_address": "exaple@example.org", - "id": "idn_29w83yL7CwVlJXylYLxcslromF1", - "linked_to": [], - "object": "email_address", - "verification": { - "status": "verified", - "strategy": "ticket" + "type": "user.created", + "object": "event", + "data": { + "id": "user_29w83sxmDNGwOuEthce5gg56FcC", + "first_name": "Example", + "last_name": "Example", + "email_addresses": [ + { + "email_address": "example@example.org", + "id": "idn_29w83yL7CwVlJXylYLxcslromF1" } - } - ], - "external_accounts": [], - "external_id": "567772", - "first_name": "Example", - "gender": "", - "id": "user_29w83sxmDNGwOuEthce5gg56FcC", - "image_url": "https://img.clerk.com/xxxxxx", - "last_name": "Example", - "last_sign_in_at": 1654012591514, - "object": "user", - "password_enabled": true, - "phone_numbers": [], - "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1", - "primary_phone_number_id": null, - "primary_web3_wallet_id": null, - "private_metadata": {}, - "profile_image_url": "https://www.gravatar.com/avatar?d=mp", - "public_metadata": {}, - "two_factor_enabled": false, - "unsafe_metadata": {}, - "updated_at": 1654012591835, - "username": null, - "web3_wallets": [] - }, - "instance_id": "ins_123", - "object": "event", - "timestamp": 1654012591835, - "type": "user.created" -} -``` + ], + "image_url": "https://img.clerk.com/xxxxxx" + } + } + ``` @@ -824,9 +404,9 @@ Follow these steps to set up your workflow(s) in the Novu dashboard: - Browse the workflow template store in the Novu dashboard. If a template matches your use case (e.g., "User Onboarding"), select it and proceed to customize it. + Browse the workflow template store in the Novu dashboard. If a template matches your use case (for example, user onboarding), select it and customize it. - @@ -834,7 +414,7 @@ Follow these steps to set up your workflow(s) in the Novu dashboard: If no template fits or you need full control, start with a blank workflow and define every step yourself. -