Sendry Docs
Recipes

Send email from a Supabase Edge Function

Trigger a Sendry email from a Deno-based Supabase Edge Function — full code, environment, and deploy walkthrough.

Supabase Edge Functions run Deno on the edge and are the canonical place to put server-side logic in a Supabase stack. This recipe shows how to call Sendry's REST API directly from an Edge Function — useful for welcome emails, receipts, on-demand notifications, or anywhere you'd otherwise reach for a Postgres trigger + cron.

What you'll build

An Edge Function send-welcome that:

  1. Receives an event (signup, order, etc.) over HTTPS.
  2. Validates a shared secret to prevent abuse.
  3. Calls POST https://api.sendry.online/v1/emails directly (no SDK — keeps the Deno bundle tiny).

Prerequisites

  • Supabase CLI installed (supabase --version).
  • Sendry API key with sending_access scope.
  • At least one verified sending domain in Sendry.

Step 1: Scaffold the function

supabase functions new send-welcome

That creates supabase/functions/send-welcome/index.ts.

Step 2: Implement the function

// supabase/functions/send-welcome/index.ts
// deno-lint-ignore-file no-explicit-any

const SENDRY_API_KEY = Deno.env.get("SENDRY_API_KEY")!;
const FUNCTION_SECRET = Deno.env.get("FUNCTION_SECRET")!;
const FROM = "Acme <hello@acme.com>";

interface RequestBody {
  email: string;
  first_name?: string;
}

Deno.serve(async (req) => {
  if (req.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  // Shared-secret check — Supabase's built-in JWT is also an option,
  // but a custom header is easier to call from external systems.
  if (req.headers.get("x-function-secret") !== FUNCTION_SECRET) {
    return new Response("Unauthorized", { status: 401 });
  }

  let body: RequestBody;
  try {
    body = (await req.json()) as RequestBody;
  } catch {
    return Response.json({ error: "invalid_json" }, { status: 400 });
  }

  if (!body.email) {
    return Response.json({ error: "missing_email" }, { status: 400 });
  }

  const firstName = body.first_name ?? "there";

  const resp = await fetch("https://api.sendry.online/v1/emails", {
    method: "POST",
    headers: {
      authorization: `Bearer ${SENDRY_API_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      from: FROM,
      to: body.email,
      subject: `Welcome to Acme, ${firstName}!`,
      html: `
        <h1>Welcome, ${escapeHtml(firstName)}!</h1>
        <p>Thanks for joining Acme. Your account is ready — sign in to get started.</p>
        <p><a href="https://acme.com/sign-in">Sign in</a></p>
      `,
      text: `Welcome, ${firstName}! Sign in: https://acme.com/sign-in`,
      tags: [
        { name: "kind", value: "lifecycle" },
        { name: "action", value: "welcome" },
      ],
    }),
  });

  if (!resp.ok) {
    const err = await resp.text();
    console.error("sendry error", resp.status, err);
    return Response.json(
      { error: "sendry_error", status: resp.status, body: err },
      { status: 502 },
    );
  }

  const { id } = (await resp.json()) as { id: string };
  return Response.json({ ok: true, id });
});

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

Step 3: Set the secrets

supabase secrets set SENDRY_API_KEY=sn_live_xxxxxxxxxxxx
supabase secrets set FUNCTION_SECRET=$(openssl rand -hex 32)

The FUNCTION_SECRET is what callers will pass in x-function-secret.

Step 4: Deploy

supabase functions deploy send-welcome --no-verify-jwt

We pass --no-verify-jwt because we're using our own shared secret. If you want callers to authenticate with a Supabase user JWT instead, drop that flag and use getUser inside the function.

Step 5: Call it

curl -X POST https://<project-ref>.supabase.co/functions/v1/send-welcome \
  -H "x-function-secret: $FUNCTION_SECRET" \
  -H "content-type: application/json" \
  -d '{ "email": "jane@example.com", "first_name": "Jane" }'

Or from the client SDK:

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

await supabase.functions.invoke("send-welcome", {
  body: { email: "jane@example.com", first_name: "Jane" },
  headers: { "x-function-secret": FUNCTION_SECRET },
});

Variants

Send from a Postgres webhook

In Supabase Studio → Database → Webhooks, create a webhook on INSERT into auth.users that POSTs to https://<project-ref>.supabase.co/functions/v1/send-welcome with the x-function-secret header. Now every signup fires your welcome email automatically — no client-side code required.

Use the Sendry SDK instead of fetch

If you prefer the typed SDK, Deno can import it from npm:

import { Sendry } from "npm:sendry-sdk";

const sendry = new Sendry(Deno.env.get("SENDRY_API_KEY")!);
await sendry.emails.send({ from: FROM, to: body.email, subject, html });

The bundle is larger but the ergonomics are better, especially for batch sends, contact upserts, or campaign triggers.

Troubleshooting

  • 401 Unauthorized: confirm x-function-secret matches what supabase secrets list shows. Edge Functions cold-start with the latest secrets, so a redeploy is unnecessary after secrets set.
  • Domain not verified from Sendry: make sure the from: domain in FROM is actually verified in Sendry → Domains.
  • CORS errors when calling from the browser: add an OPTIONS preflight handler that returns the appropriate access-control-allow-* headers, or invoke via supabase.functions.invoke() which handles CORS for you.

On this page