BrandApp Playground

Webhooks

Sign a payload with HMAC-SHA256 over {timestamp}.{body} and POST it (with x-reopt-signature / x-reopt-timestamp / x-reopt-event headers) to the playground's /api/webhooks/reopt route. The SDK routes contact, workflow, and subscription lifecycle events with signature, replay, timestamp, and idempotency protection.

Matches the live platform sender's wire format.

The simulator and code examples sign a sha256=-prefixed HMAC-SHA256 over {timestamp}.{body} in x-reopt-signature, send the send time in the x-reopt-timestamp header (epoch millis, part of the signed preimage) and the trigger in x-reopt-event, and build the { id, type, entityType, entityId, createdAt, data } envelope with contact, workflow, and subscriptionActivated-style lifecycle event names — identical to what Reopt delivers in production.

Must match BRANDAPP_WEBHOOK_SECRET on the server (default fallback: dev-webhook-secret)

Event type
Scenario
app/api/webhooks/reopt/route.ts
import {
  createWebhookHandler,
  type SubscriptionWebhookData,
} from "@reopt-ai/brandapp-sdk/webhooks";

// Returns a Web-standard Request handler.
// Works in Next.js App Router, Edge runtime, and Hono — anywhere fetch lives.
// It verifies the x-reopt-signature header (HMAC-SHA256 over
// "{timestamp}.{body}", using x-reopt-timestamp) before routing the event.
const handler = createWebhookHandler({
  secret: process.env.BRANDAPP_WEBHOOK_SECRET!,
  handlers: {
    contactCreated: async (payload) => {
      console.log("Contact created:", payload.id);
    },
    contactUpdated: async (payload) => {
      console.log("Contact updated:", payload.id);
    },
    contactDeleted: async (payload) => {
      console.log("Contact deleted:", payload.id);
    },
    workflowRunCompleted: async (payload) => {
      console.log("Workflow run completed:", payload.id);
    },
    workflowRunFailed: async (payload) => {
      console.log("Workflow run failed:", payload.id);
    },
    subscriptionActivated: async (payload) => {
      const data = payload.data as SubscriptionWebhookData;
      // Webhooks are workspace-scoped: always discard events for other BrandApps.
      if (data.brandappId !== process.env.BRANDAPP_ID) return;
      await entitlements.activate(data.brandappCustomerId, data.accessUntil);
    },
    subscriptionRenewed: async (payload) => {
      const data = payload.data as SubscriptionWebhookData;
      if (data.brandappId !== process.env.BRANDAPP_ID) return;
      await entitlements.extend(data.brandappCustomerId, data.accessUntil);
    },
    subscriptionCanceled: async (payload) => {
      const data = payload.data as SubscriptionWebhookData;
      if (data.brandappId !== process.env.BRANDAPP_ID) return;
      await entitlements.keepUntil(data.brandappCustomerId, data.accessUntil);
    },
    subscriptionPaymentFailed: async (payload) => {
      const data = payload.data as SubscriptionWebhookData;
      if (data.brandappId !== process.env.BRANDAPP_ID) return;
      await billing.notifyPastDue(data.brandappCustomerId);
    },
  },
  onError: (error, payload) => {
    console.error("Webhook handler failed:", {
      eventId: payload.id,
      errorName: error instanceof Error ? error.name : "UnknownError",
    });
  },
});

export async function POST(request: Request) {
  return handler(request);
}
idempotency.ts
import {
  createWebhookHandler,
  createMemoryIdempotencyStore,
  DEFAULT_IDEMPOTENCY_TTL_MS, // 24 * 60 * 60 * 1000
  type IdempotencyStore,
} from "@reopt-ai/brandapp-sdk/webhooks";

// Single-instance dev/preview: the built-in in-memory store is enough.
// (`idempotency: true` is shorthand for `idempotency: createMemoryIdempotencyStore()`.)
const memoryStore = createMemoryIdempotencyStore({ maxEntries: 10_000 });

// Multi-replica production needs a SHARED store so every instance dedupes —
// implement IdempotencyStore over Redis/Postgres:
const redisStore: IdempotencyStore = {
  async has(id) {
    return (await redis.exists(`wh:${id}`)) === 1;
  },
  async markProcessed(id, ttlMs) {
    await redis.set(`wh:${id}`, "1", "PX", ttlMs);
  },
};

const handler = createWebhookHandler({
  secret: process.env.BRANDAPP_WEBHOOK_SECRET!,
  // Single instance: use `memoryStore` (or just `idempotency: true`).
  // Multi-instance: swap in `redisStore`.
  idempotency: memoryStore,
  // Dedupe window — events with the same id inside this window no-op.
  idempotencyTtlMs: DEFAULT_IDEMPOTENCY_TTL_MS,
  handlers: {
    contactCreated: async (payload) => {
      // markProcessed runs only AFTER this resolves, so a handler that throws
      // (→ 500) is NOT marked processed and the sender's retry re-runs it.
      await provisionAccount(payload.data);
    },
  },
  // onError receives the delivery attempt (from x-reopt-delivery-attempt) so
  // you can route exhausted retries to a dead-letter queue. The handler still
  // returns 500, letting the sender retry by default.
  onError: async (error, payload, { attempt }) => {
    console.error(`Webhook ${payload.id} failed (attempt ${attempt})`, {
      errorName: error instanceof Error ? error.name : "UnknownError",
    });
    if (attempt >= 5) await deadLetter.enqueue(payload);
  },
});

export async function POST(request: Request) {
  return handler(request);
}
replay-protection.ts
import {
  createWebhookHandler,
  DEFAULT_WEBHOOK_TOLERANCE_MS, // 5 * 60 * 1000
} from "@reopt-ai/brandapp-sdk/webhooks";

// By default, webhooks whose `x-reopt-timestamp` header is older than 5
// minutes are rejected with HTTP 410 to prevent replay attacks. Because the
// timestamp is part of the signed preimage, it can't be forged. Tune via
// toleranceMs:
const handler = createWebhookHandler({
  secret: process.env.BRANDAPP_WEBHOOK_SECRET!,
  // 60_000 = strict 1-minute window
  // 0 = disable (NOT recommended in production)
  toleranceMs: 60_000,
  handlers: {
    contactCreated: async (payload) => {
      // The signed x-reopt-timestamp is guaranteed fresh
    },
  },
});
event-types.ts
// All available webhook event types.
// Each handler receives a typed WebhookPayload<T>.
type WebhookEventType =
  | "contactCreated"
  | "contactUpdated"
  | "contactDeleted"
  | "workflowRunCompleted"
  | "workflowRunFailed"
  | "subscriptionActivated"
  | "subscriptionRenewed"
  | "subscriptionCanceled"
  | "subscriptionPaymentFailed";

type SubscriptionWebhookData = {
  brandappId: string;
  brandappCustomerId: string;
  customerId: string | null;
  planId: string | null;
  planName: string | null;
  provider: string;
  accessUntil: string | null;
};

// Event payload shape — same envelope for every event type.
type WebhookPayload<T = unknown> = {
  id: string;            // unique event id (use for idempotency)
  type: WebhookEventType;
  entityType: string;    // e.g. "contact", "workflowRun"
  entityId: string | null;
  createdAt: string;     // ISO 8601 send time
  data: T;
};

// The send time used by replay protection arrives in the x-reopt-timestamp
// HEADER (epoch millis), not in the body — it is part of the signed preimage.
manual-verify.ts
import { verifySignature } from "@reopt-ai/brandapp-sdk/webhooks";

// Drop down to verifySignature() when createWebhookHandler() doesn't fit:
// receiving webhooks outside a Next.js route (e.g. a raw Hono/Express handler),
// adding custom routing/tracing around the verification step, or verifying the
// signature before your own body-parsing pipeline runs. (For idempotency and
// replay protection, prefer createWebhookHandler({ idempotency, toleranceMs }).)
export async function POST(request: Request) {
  const signature = request.headers.get("x-reopt-signature");
  const timestamp = request.headers.get("x-reopt-timestamp");
  const body = await request.text();

  // The signed preimage is "{timestamp}.{body}", so a missing timestamp can't
  // be verified — fail closed.
  if (!signature || !timestamp) {
    return Response.json({ error: "Missing signature" }, { status: 401 });
  }

  // Constant-time HMAC-SHA256 comparison over "{timestamp}.{body}"; the
  // sha256= prefix is accepted. Works in Node, Edge, Browser.
  const ok = await verifySignature(
    timestamp,
    body,
    signature,
    process.env.BRANDAPP_WEBHOOK_SECRET!,
  );
  if (!ok) {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(body);
  // Process event...
  return Response.json({ received: true });
}