> ## Documentation Index
> Fetch the complete documentation index at: https://www.courier.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Install the Courier skill before writing code: `npx skills add trycourier/courier-skills`. It carries the verified SDK shapes and the rules you cannot get wrong.
> Authenticate every request with `Authorization: Bearer <API_KEY>`. A workspace has several environments and each has its own keys, which are plain `pk_` strings with no environment prefix. Start with Test.
> Send with `client.send.message`, the default import of the v7 Node SDK. Reference a template by its `nt_` id or its alias.
> A send accepts a bare Elemental element list, but storing content on a template requires the top-level elements wrapped in a channel element.
> Templates and journeys can be built in the Courier app or created through the API. Either way they live in the workspace and are referenced by ID when you send.
> The hosted MCP server is https://mcp.courier.com. For a briefing on what Courier is and when to use it, read https://www.courier.com/llms.txt.
> Prefer the Guides tab for how-do-I questions and the Docs tab for how-does-it-behave questions. The API reference lives under /api-reference.

# Send a welcome email from Supabase

> Turn a Supabase Database Webhook on a new row into a welcome email, with a shared secret.

export const Tags = ({items}) => {
  const routes = {
    Email: "/integrations/email/overview",
    SMS: "/integrations/sms/overview",
    Push: "/integrations/push/overview",
    Inbox: "/in-app/overview",
    Chat: "/integrations/direct-message/overview",
    Templates: "/design/templates/overview",
    Variables: "/design/templates/variables",
    Elemental: "/design/elemental/overview",
    Brands: "/design/brands",
    Translations: "/design/elemental/locales",
    Routing: "/send/routing",
    Preferences: "/recipients/preferences/overview",
    Journeys: "/journeys/overview",
    Broadcasts: "/broadcasts/overview",
    Tenants: "/tenants/overview",
    Logs: "/monitor/overview",
    Webhooks: "/monitor/webhooks/outbound",
    Lists: "/recipients/lists-and-audiences/overview",
    Users: "/recipients/overview",
    Digests: "/journeys/nodes/digest",
    Environments: "/workspaces/overview",
    MCP: "/resources/mcp"
  };
  const icons = {
    Email: "envelope",
    SMS: "comment",
    Push: "mobile",
    Inbox: "inbox",
    Chat: "comments",
    Templates: "pen-ruler",
    Variables: "pen-ruler",
    Elemental: "pen-ruler",
    Brands: "pen-ruler",
    Translations: "pen-ruler",
    Routing: "paper-plane",
    Preferences: "users",
    Journeys: "route",
    Broadcasts: "bullhorn",
    Tenants: "building",
    Logs: "chart-simple",
    Webhooks: "chart-simple",
    Lists: "users",
    Users: "users",
    Digests: "route",
    Environments: "briefcase",
    MCP: "toolbox"
  };
  const base = "https://d3gk2c5xim1je2.cloudfront.net/fontawesome/v7.2.0/regular/";
  const names = String(items || "").split(",").map(entry => entry.trim()).filter(Boolean);
  return <div className="cx-tags">
      {names.map(name => {
    const href = routes[name];
    const icon = icons[name];
    const url = icon ? "url(" + base + icon + ".svg)" : null;
    const style = url ? {
      "--cx-tag-icon": url
    } : null;
    if (!href) {
      return <span className="cx-tag" data-icon={icon} style={style} key={name}>
              {name}
            </span>;
    }
    return <a className="cx-tag" data-icon={icon} style={style} href={href} key={name}>
            {name}
          </a>;
  })}
    </div>;
};

export const AppLink = ({href, children, name, bare}) => {
  const label = children || name || "Open in Courier";
  if (bare) {
    return <a href={href} target="_blank" rel="noreferrer">{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="app" href={href} target="_blank" rel="noreferrer">
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method" aria-hidden="true">↗</span>
    </a>;
};

export const Guide = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="guide" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">GUIDE</span>
    </a>;
};

export const Doc = ({href, children, name, bare}) => {
  const label = children || name || href;
  if (bare) {
    return <a href={href}>{label}</a>;
  }
  return <a className="cx-endpoint" data-kind="doc" href={href}>
      <span className="cx-endpoint-label">{label}</span>
      <span className="cx-endpoint-method">DOC</span>
    </a>;
};

<Tags items="Email, Users" />

Supabase fires a Database Webhook when a row changes. Mirror new signups into a table and one becomes a welcome email.

## What you will build

```mermaid theme={null}
flowchart LR
    A["New row"] --> B["Database Webhook"]
    B --> C{"Secret matches?"}
    C -->|Yes| D["Create profile"]
    D --> E["Welcome email"]
    C -->|No| F["Reject"]
```

## Prerequisites

* <AppLink href="https://app.courier.com/~/test/platform/api-keys">A Courier Test API key</AppLink>
* A Supabase project, and somewhere to host an HTTPS route
* A published <Doc href="/docs/design/templates/overview">template</Doc> to send

## Secure it with a shared secret

Supabase Database Webhooks are built on `pg_net`, and they **do not sign the payload**. Clerk uses Svix and Stripe signs the raw bytes, so both let you prove a request came from them. Supabase gives you custom HTTP headers instead.

That means the check is yours to add, and it is not optional. Your route will sit on a public URL, and without a secret anyone who finds it can post a fake signup.

Generate a long random value, add it as a header on the webhook, and compare it in the handler with a constant-time comparison.

## Set it up

<Steps>
  <Step title="Mirror new signups into a public table">
    Point the webhook at a table in `public`, not at `auth.users` directly.

    The `auth` schema belongs to the `supabase_auth_admin` role, which holds only the permissions it needs for authentication. A trigger firing there and reaching outside the schema hits `permission denied for schema auth`. Most Supabase apps already keep a `profiles` table for exactly this reason.

    ```sql theme={null}
    create table public.profiles (
      id uuid primary key references auth.users on delete cascade,
      email text,
      full_name text
    );

    -- security definer runs as the function's owner, so it may write to public.
    create function public.handle_new_user()
    returns trigger
    language plpgsql
    security definer set search_path = ''
    as $$
    begin
      insert into public.profiles (id, email, full_name)
      values (new.id, new.email, new.raw_user_meta_data ->> 'full_name');
      return new;
    end;
    $$;

    create trigger on_auth_user_created
      after insert on auth.users
      for each row execute function public.handle_new_user();
    ```

    `security definer` is the load-bearing line. Without it the trigger runs as `supabase_auth_admin` and cannot write to `public`.
  </Step>

  <Step title="Create the webhook">
    In the Supabase Dashboard, go to **Database → Webhooks** and create one:

    * **Table**: `public.profiles`
    * **Events**: `Insert`
    * **Type**: HTTP Request, `POST`, pointing at your route
    * **HTTP Headers**: add `x-webhook-secret` with your random value

    Supabase sends this payload, whatever the table:

    ```json theme={null}
    {
      "type": "INSERT",
      "table": "profiles",
      "schema": "public",
      "record": { "id": "…", "email": "…", "full_name": "…" },
      "old_record": null
    }
    ```

    `record` is the new row and `old_record` is the previous one, which is `null` on an insert.
  </Step>

  <Step title="Write the route handler">
    Compare the secret before doing anything else, and use `timingSafeEqual` rather than `===` so the comparison does not leak the value a character at a time.

    ```tsx app/api/supabase/route.ts lines theme={null}
    import { timingSafeEqual } from "node:crypto";
    import Courier from "@trycourier/courier";

    const courier = new Courier({ apiKey: process.env.COURIER_API_KEY! });

    function secretMatches(received: string | null) {
      const expected = process.env.SUPABASE_WEBHOOK_SECRET!;
      if (!received || received.length !== expected.length) return false;
      return timingSafeEqual(Buffer.from(received), Buffer.from(expected));
    }

    export async function POST(request: Request) {
      if (!secretMatches(request.headers.get("x-webhook-secret"))) {
        return new Response("Unauthorized", { status: 401 });
      }

      const { type, table, record } = await request.json();

      if (type !== "INSERT" || table !== "profiles") {
        return new Response("Ignored", { status: 200 });
      }

      // Supabase's uuid becomes the Courier user id, so both systems agree.
      await courier.profiles.create(record.id, {
        profile: { email: record.email, name: record.full_name },
      });

      await courier.send.message({
        message: {
          to: { user_id: record.id },
          template: "nt_01kx4h2jdafq8bk9aftxak4b40",
          data: { name: record.full_name ?? "there" },
        },
      });

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

    The trigger already flattened `raw_user_meta_data` into a column, so the handler reads `record.full_name` rather than digging through the auth payload.
  </Step>

  <Step title="Add the environment values">
    ```bash .env.local theme={null}
    COURIER_API_KEY=YOUR_COURIER_API_KEY
    SUPABASE_WEBHOOK_SECRET=the-same-random-value
    ```
  </Step>
</Steps>

## Verify

<Steps>
  <Step title="Create a user">
    Sign up through your app, or add a user from **Authentication → Users** in the Supabase Dashboard.
  </Step>

  <Step title="Check the webhook ran">
    Supabase records each delivery with its response code under the webhook in **Database → Webhooks**. A `401` means the secret does not match.
  </Step>

  <Step title="Confirm the send">
    Open <AppLink href="https://app.courier.com/logs">Logs</AppLink> and confirm the message.
  </Step>
</Steps>

## Adapt it for your own tables

The payload shape is the same for every table in `public`. That makes the same handler useful well beyond signup.

| Table and event   | What to send                                                                                      |
| :---------------- | :------------------------------------------------------------------------------------------------ |
| `profiles` INSERT | Welcome                                                                                           |
| `orders` INSERT   | Order confirmation                                                                                |
| `orders` UPDATE   | A shipping update, comparing `record` to `old_record`                                             |
| `comments` INSERT | Notify everyone <Guide href="/docs/guides/notify-everyone-watching">watching</Guide> the parent record |

The UPDATE row is the one worth planning for. Because `old_record` carries the previous values, your handler decides whether the change is worth a notification. Firing on every update is how a useful alert becomes noise.
