SendBeam

Trigger.dev setup

Connect SendBeam to Trigger.dev: what you need, the steps, and the code.

View as Markdown

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

  1. Create an API key. Settings → API keys in SendBeam, with contacts:write and transactional:send (and lists:write if you add a list step: POST /api/v1/lists/{id}/contacts with the same helper). The full key is shown once. Add it to your Trigger.dev project as the environment variable SENDBEAM_API_KEY — never in the task’s source.
  2. 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.
  3. 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.
  4. 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/contacts answers 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"> in X-SendBeam-Signature; the delivery id in X-SendBeam-Delivery is 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/transactional needs no marketing consent.

← Back to the Trigger.dev integration