Trigger.dev

Verify a Hypeline delivery with the standardwebhooks library in your own route, then run a Trigger.dev task with the event id as the idempotency key.

Trigger.dev runs background tasks in your own codebase. Trigger.dev v4 has no managed incoming-webhook trigger, so the pattern is a route you own that verifies the delivery and calls tasks.trigger(). Because that route is your code, you can verify with an off-the-shelf Standard Webhooks library, which proves the interop claim: Hypeline signs exactly the way OpenAI, Anthropic, Supabase, and Twilio webhooks do.

Step 1: register a destination pointing at your route

Deploy a route (for example /api/hypeline) and register its public HTTPS URL as a Hypeline destination:

bash
curl -X POST https://api.hypeline.io/v1/alerts/{alert_id}/destinations \
  -H "Authorization: Bearer $YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"kind":"webhook","url":"https://your-app.com/api/hypeline","delivery_mode":"instant"}'

Store the whsec_ secret from the response as HYPELINE_WEBHOOK_SECRET.

Step 2: verify in the route with the standardwebhooks library

Install the library (npm i standardwebhooks). It expects the raw body bytes and the three webhook-* headers, and it does the whsec_ key derivation for you. Read the RAW body: verifying a re-serialized object breaks the HMAC.

typescript
// app/api/hypeline/route.ts (Next.js App Router)
import { Webhook } from "standardwebhooks";
import { tasks } from "@trigger.dev/sdk/v3";
import type { handlePageChange } from "@/trigger/handlePageChange";

const wh = new Webhook(process.env.HYPELINE_WEBHOOK_SECRET!);

export async function POST(req: Request) {
  const body = await req.text(); // RAW body, not req.json()
  const headers = {
    "webhook-id": req.headers.get("webhook-id")!,
    "webhook-timestamp": req.headers.get("webhook-timestamp")!,
    "webhook-signature": req.headers.get("webhook-signature")!,
  };

  let event: any;
  try {
    // verify() checks the HMAC and the 5-minute timestamp freshness, then
    // returns the parsed payload. It throws on a bad signature or a stale POST.
    event = wh.verify(body, headers);
  } catch {
    return new Response("invalid signature", { status: 401 });
  }

  // Dedup anchor: the event UUIDv7 (== webhook-id). Trigger.dev drops a repeat.
  await tasks.trigger<typeof handlePageChange>("handle-page-change", event, {
    idempotencyKey: event.id,
  });

  return new Response("ok", { status: 200 });
}

The Webhook constructor takes the whsec_ secret directly. If your version of the library wants the bare base64 without the prefix, strip it once with secret.replace(/^whsec_/, ""). Confirm against the library version you install.

Express or Hono follow the same shape: read the raw body (express.raw({ type: "*/*" }) or Hono's c.req.text()), pass the three headers to wh.verify, then trigger.

Step 3: define the task

typescript
// trigger/handlePageChange.ts
import { task } from "@trigger.dev/sdk/v3";

export const handlePageChange = task({
  id: "handle-page-change",
  run: async (event: {
    id: string;
    url: string;
    title: string;
    diff?: unknown;
    tags: string[];
  }) => {
    // event is the StreamEvent Hypeline delivered.
    return { handled: event.url };
  },
});

idempotencyKey: event.id is the teachable moment: delivery is at-least-once, and the event id is a UUIDv7 that never repeats for distinct events, so passing it as the idempotency key makes a redelivery a no-op.

Defense in depth: verify the content signature

The HMAC above proves the delivery reached your route intact. For end-to-end content authenticity that does not depend on the transport, also verify the event's Ed25519 signature against the published JWKS inside the task. It survives any re-serialization on the way in. The verifying events page has the Node recipe you can drop into the task before acting on event.

Notes

  • This recipe pins delivery_mode: "instant". A digest destination posts a batch wrapper with an events array instead of a single event, so the route would iterate it; that shape is out of scope here.
  • The destination URL must be public HTTPS. A localhost route URL is rejected at registration by the SSRF guard, so deploy the route or tunnel to it.