Skip to content

Node.js Webhooks

The Node.js client can receive webhooks for you. It checks the signature, skips webhooks you have already handled and calls your code for each event type.

import { handler, WebhookEventTypes } from "@divergent.dev/connect";
const handle = handler({
secrets: [process.env.CONNECT_WEBHOOK_SECRET],
publicUrl: "https://yourcompany.com/webhooks/connect",
on: {
[WebhookEventTypes.SmsDelivered]: async (event) => { console.log(event.data.id, event.data.status); },
[WebhookEventTypes.EmailBounced]: async (event) => { console.log(event.data.recipient); },
},
});
OptionDescription
secretsYour webhook’s secret. While rotating, pass the new and the old one
publicUrlThe URL you gave Connect for the webhook
onYour code for each event type, keyed by a WebhookEventTypes constant such as WebhookEventTypes.SmsDelivered. event.data is typed for the event
onAnyRuns for every event, after its on handler
onErrorCalled when your code throws. Logs the error by default
dedupeWhere handled Webhook-Ids are kept. In memory by default. See below

handle takes a standard Request and returns a Response:

WhenResponse
The signature doesn’t match, or has expired401
You already handled this webhook200, and your code isn’t run
Your code ran, or there is none for the event type200
Your code throws500, and Connect retries later

With anything that uses standard Request and Response objects, such as Next.js, SvelteKit, Hono, Bun or Deno, pass the request straight in. For example, in a Next.js route handler:

export const POST = (request: Request) => handle(request);

With Express or node:http, use toNodeHandler. With Express, keep the body raw, as the signature covers its exact bytes:

import express from "express";
import { toNodeHandler } from "@divergent.dev/connect/node";
const app = express();
app.post("/webhooks/connect", express.raw({ type: "application/json" }), toNodeHandler(handle));

The handler remembers which webhooks it has handled for 48 hours, but only in its own process. If you run more than one instance, store them somewhere shared, such as Redis, with SET NX:

import { acquiredClaim, duplicateClaim, handler, type WebhookDeduplicator } from "@divergent.dev/connect";
import { createClient } from "redis";
const redis = await createClient({ url: process.env.REDIS_URL }).connect();
const dedupe: WebhookDeduplicator = {
async claim(webhookId) {
const key = `connect-webhook:${webhookId}`;
const claimed = await redis.set(key, "1", { NX: true, EX: 172800 });
return claimed ? acquiredClaim(() => redis.del(key)) : duplicateClaim;
},
};
const handle = handler({ secrets: [process.env.CONNECT_WEBHOOK_SECRET], dedupe, on: { /* ... */ } });

The key lasts 48 hours, longer than Connect keeps retrying. If your code throws, the key is deleted so the retry is handled.

To check a webhook yourself, use verify with the raw body. It returns the event, or throws a WebhookVerificationError whose reason says why it failed. It doesn’t skip repeats.

import { verify, WebhookVerificationError } from "@divergent.dev/connect";
try {
const event = verify({ url: "https://yourcompany.com/webhooks/connect", headers: req.headers, body: rawBody, secret });
} catch (e) {
if (e instanceof WebhookVerificationError) console.log(e.reason);
}