Trigger.dev setup
Connect SendBeam to Trigger.dev: what you need, the steps, and the code.
Via API
Trigger.dev runs background jobs as TypeScript tasks with retries, queues and a dashboard. SendBeam is a JSON API behind one header, so a task that welcomes a customer or sends an order confirmation is a few lines of fetch, and SendBeam's own webhooks can start a task when a contact joins or clicks.
Before you start
- A Trigger.dev project (v3 SDK)
- A SendBeam API key with the permissions the task needs
- A verified sending domain, for site email
Set it up
- Create an API key. Settings → API keys in SendBeam, with
contacts:writeandtransactional:send(andlists:writeif you add a list step:POST /api/v1/lists/{id}/contactswith the same helper). The full key is shown once. Add it to your Trigger.dev project as the environment variableSENDBEAM_API_KEY— never in the task’s source. - Add the task. Copy the file below into your
trigger/folder. Its payload is typed, and every SendBeam call that answers 5xx or 429 is retried by Trigger.dev’s own retry policy. - Trigger it and test. From your app:
await tasks.trigger<typeof welcomeContact>("sendbeam-welcome", { email, name }). Run it once with your own address and check the contact and the email in SendBeam before wiring it to real signups. - Optional: let SendBeam start tasks. Under Settings → Webhooks, add an endpoint pointing at a route in your app; the route verifies the signature and triggers a task with the event. The second file below is that route.
Code
The task: welcome a new customer
trigger/sendbeam-welcome.ts
import { task, logger } from "@trigger.dev/sdk/v3";
type WelcomePayload = {
email: string;
name?: string;
};
async function sendbeam<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "x-api-key": process.env.SENDBEAM_API_KEY!, "content-type": "application/json" },
body: JSON.stringify(body),
});
// 409 means "already there" for a contact or a list membership: not a failure.
if (res.status === 409) return (await res.json()) as T;
if (!res.ok) throw new Error(`SendBeam ${url} → ${res.status}: ${await res.text()}`);
return (await res.json()) as T;
}
export const welcomeContact = task({
id: "sendbeam-welcome",
retry: { maxAttempts: 5, factor: 2, minTimeoutInMs: 1_000, maxTimeoutInMs: 60_000 },
run: async (payload: WelcomePayload) => {
const [first, ...rest] = (payload.name ?? "").trim().split(/\s+/);
const contact = await sendbeam<{ contact?: { id: string } }>("https://sendbeam.io/api/v1/contacts", {
email: payload.email,
first_name: first || undefined,
last_name: rest.join(" ") || undefined,
source: "trigger.dev",
});
logger.info("contact ready", { email: payload.email });
await sendbeam("https://sendbeam.io/api/v1/transactional", {
to: { email: payload.email, name: payload.name },
subject: "Welcome aboard",
html: `<p>Hi ${first || "there"}, thanks for signing up.</p>`,
});
return { contactId: contact.contact?.id ?? null };
},
});
The route: SendBeam events start a task
app/api/sendbeam/route.ts (Next.js App Router; any framework that gives you the raw body works)
import { createHmac, timingSafeEqual } from "node:crypto";
import { tasks } from "@trigger.dev/sdk/v3";
import type { onSendbeamEvent } from "@/trigger/sendbeam-event";
const TOLERANCE_SECONDS = 300;
function verify(raw: string, header: string | null, secret: string): boolean {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
const got = parts.v1 ?? "";
return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
export async function POST(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers.get("x-sendbeam-signature"), process.env.SENDBEAM_WEBHOOK_SECRET!)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
// Idempotent by delivery id: Trigger.dev drops a duplicate idempotency key.
await tasks.trigger<typeof onSendbeamEvent>("sendbeam-event", event, {
idempotencyKey: req.headers.get("x-sendbeam-delivery") ?? undefined,
});
return new Response("ok");
}
trigger/sendbeam-event.ts
import { task } from "@trigger.dev/sdk/v3";
export const onSendbeamEvent = task({
id: "sendbeam-event",
run: async (event: { type: string; data: Record<string, unknown> }) => {
switch (event.type) {
case "contact.created":
// e.g. create the person in your CRM
break;
case "email.clicked":
// e.g. notify sales that a lead is warm
break;
}
return { handled: event.type };
},
});
Things to know
- Keep the API key in Trigger.dev’s environment variables, one per environment, so a staging deploy never writes to your production list.
POST /api/v1/contactsanswers 409 for an address that already exists; the task treats that as success so a retried run never fails on its own earlier work.- SendBeam’s signature is
t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">inX-SendBeam-Signature; the delivery id inX-SendBeam-Deliveryis the idempotency key. Both are documented under Webhooks. - Only add people to a marketing list if they agreed to hear from you. Site email through
/api/v1/transactionalneeds no marketing consent.