Guide

Webhook to Send Email: How to Trigger Emails from Webhooks (and the Reverse)

“Webhook to send email” is one of those searches that means two opposite things depending on who is typing it:

  1. Triggering an outgoing email when a webhook fires — a payment succeeds, a form is submitted, a deploy finishes, and you want an email to go out.
  2. Receiving an incoming email as a webhook — someone sends an email to an address, and you want it delivered to your API as a JSON POST.

This guide covers both directions properly, with working code. If you are here for #2 — turning inbound email into webhooks — that is exactly what InboxBridge does, and you can jump straight to that section.

Part 1: Send an email when a webhook fires

A webhook on its own cannot send anything — it is just an HTTP POST from some system (Stripe, GitHub, Shopify, your form provider) to a URL you give it. To turn that POST into an email, you need a small piece of glue in the middle. The architecture is always the same three boxes:

┌─────────────────┐      ┌──────────────────┐      ┌─────────────────┐
│   Event source   │ POST │   Your endpoint   │ API  │    Email API     │
│ (Stripe, GitHub, ├─────►│ (Express route or ├─────►│ (Resend, SendGrid│
│  form, CI, ...)  │      │  serverless fn)   │      │  Postmark, SES)  │
└─────────────────┘      └──────────────────┘      └─────────────────┘

The event source fires the webhook. Your endpoint receives it, decides what the email should say, and calls a transactional email API to actually send it. You do not run a mail server at any point — deliverability, SPF/DKIM, bounce handling, and the SMTP conversation are all the email API's problem.

We will build the endpoint in Node.js with Express and send through Resend, because its API is the smallest to demonstrate. Everything here translates directly to SendGrid, Postmark, or Amazon SES — only the client library and the shape of the send call change.

Step 1: A minimal receiving endpoint

Start with an Express app that accepts a webhook POST and sends one email. This example imagines an “order created” event, but the pattern is identical for any source:

// server.js
import express from "express";
import { Resend } from "resend";

const app = express();
const resend = new Resend(process.env.RESEND_API_KEY);

app.post("/webhooks/order-created", express.json(), async (req, res) => {
  const event = req.body;

  await resend.emails.send({
    from: "Orders <orders@yourdomain.com>",
    to: event.customerEmail,
    subject: `Order ${event.orderId} confirmed`,
    html: `<p>Thanks! Your order <strong>${event.orderId}</strong> is confirmed.</p>`,
  });

  res.status(200).json({ ok: true });
});

app.listen(3000);

Point your event source at https://your-server.com/webhooks/order-created, and every event produces an email. That is the whole idea — but this version has two real bugs waiting to happen, and fixing them is what separates a demo from something you can run in production.

Step 2: Idempotency — the duplicate-email bug

Webhook providers deliver at least once, not exactly once. If your endpoint is slow to respond, times out, or returns a non-2xx during a blip, the provider retries — and the naive handler above will happily email your customer twice. Stripe, GitHub, and Shopify all document this behavior explicitly; it is not an edge case, it is the contract.

The fix has two layers. First, dedupe on the event's stable ID (every serious webhook provider includes one — event.id in Stripe, X-GitHub-Delivery in GitHub) so a retried delivery is recognized and skipped. Second, pass an idempotency key to the email API itself, so that even if your dedupe store misses (a race between two concurrent retries, a restart that wiped an in-memory set), the email provider refuses to send the same message twice. Resend accepts an idempotency key directly on the send call:

// A tiny dedupe store. In production use Redis with a TTL,
// or a database table with a unique constraint on eventId.
const processedEvents = new Set();

app.post("/webhooks/order-created", express.json(), async (req, res) => {
  const event = req.body;
  const eventId = event.id; // the provider's stable event ID

  // Layer 1: skip deliveries we've already handled.
  if (processedEvents.has(eventId)) {
    return res.status(200).json({ ok: true, duplicate: true });
  }

  const { error } = await resend.emails.send(
    {
      from: "Orders <orders@yourdomain.com>",
      to: event.customerEmail,
      subject: `Order ${event.orderId} confirmed`,
      html: `<p>Thanks! Your order <strong>${event.orderId}</strong> is confirmed.</p>`,
    },
    // Layer 2: the email API dedupes on this key for ~24h.
    { idempotencyKey: `order-confirmed/${eventId}` }
  );

  // Resend's SDK doesn't throw on failure — it returns { data, error }.
  // A failed send must not mark the event processed; return a 500 so the
  // webhook provider redelivers (more on retries in the next section).
  if (error) {
    return res.status(500).json({ error: "email send failed" });
  }

  processedEvents.add(eventId);
  res.status(200).json({ ok: true });
});

Two details matter here. Mark the event as processed after the send succeeds, not before — otherwise a crash mid-send leaves the event marked done with no email sent. And derive the idempotency key from the event ID plus the purpose (order-confirmed/evt_123), so the same event can still legitimately trigger two different emails (say, a customer receipt and an internal notification) without one blocking the other.

If you use SendGrid or SES, which do not expose idempotency keys on the send call, the database layer does all the work: insert the event ID into a table with a unique constraint before sending, and let the constraint violation tell you it is a duplicate.

Step 3: Retry handling — in both directions

There are two retry loops to think about, and they point in opposite directions.

Retries from the event source to you. Lean on these — they are free reliability. If the email API is down or returns a 5xx, respond to the webhook with a 500 and let the provider redeliver later. Because you have idempotency in place, the retry is safe. The one rule: respond quickly. If building the email involves slow work (fetching order details, rendering a template), acknowledge the webhook first and do the work in a queue or background job so you never trip the provider's timeout.

Retries from you to the email API. Transient failures — a network hiccup, a 429 rate limit — deserve a couple of immediate retries with backoff before you give up and lean on the webhook provider's slower retry schedule. One SDK-specific trap: Resend never throws on failure. Every call resolves to { data, error } — even network errors come back in error — so a try/catch around the send will never fire. Check error explicitly:

async function sendWithRetry(payload, options, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const { data, error } = await resend.emails.send(payload, options);
    if (!error) return data;

    // statusCode is null on network-level failures — retry those too.
    const retryable =
      error.statusCode === null ||
      error.statusCode === 429 ||
      error.statusCode >= 500;
    if (!retryable || i === attempts - 1) {
      throw new Error(`Email send failed (${error.name}): ${error.message}`);
    }
    // 500ms, 1s, 2s — with the idempotency key, retries can't double-send.
    await new Promise((r) => setTimeout(r, 500 * 2 ** i));
  }
}

Do not retry on 4xx errors other than 429 — a rejected recipient address or a malformed payload will fail identically every time, and retrying just burns quota.

Step 4: Verify the webhook is genuine

Your endpoint is a public URL that sends email on demand — exactly the kind of thing you do not want strangers to be able to trigger. Every reputable webhook provider signs its deliveries: Stripe with Stripe-Signature, GitHub with X-Hub-Signature-256, most others with some HMAC over the raw request body. Verify the signature before doing anything else, and read the body as raw bytes first, because the HMAC is computed over the exact bytes sent:

import crypto from "crypto";

// Generic HMAC-SHA256 verification; check your provider's header name
// and format. For Stripe specifically, use stripe.webhooks.constructEvent.
app.post(
  "/webhooks/order-created",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["x-webhook-signature"] ?? "";
    const expected = crypto
      .createHmac("sha256", process.env.WEBHOOK_SECRET)
      .update(req.body) // raw Buffer, not parsed JSON
      .digest("hex");

    const a = Buffer.from(String(signature));
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // ... dedupe, send, respond as above
  }
);

Use a timing-safe comparison, never ===, and store the secret in an environment variable. If a provider offers an official verification helper (Stripe and GitHub both do), prefer it over hand-rolling.

The alternatives: SendGrid, Postmark, SES

Nothing above is Resend-specific. With SendGrid you call sgMail.send() with a nearly identical payload; it is the incumbent, with a large free tier and heavier dashboard. Postmark is the deliverability-obsessed option — strictly transactional, excellent bounce visibility, and the same provider InboxBridge uses for inbound mail. Amazon SES is the cheapest at volume by a wide margin, at the cost of more setup (IAM, sandbox exit, reputation management is on you). If you are sending a handful of notification emails from webhooks, pick whichever you already have credentials for; the architecture does not change.

The no-code alternative: Zapier or Make

If this email is an internal notification and nobody on the team wants to maintain an endpoint, Zapier's “Webhooks by Zapier” trigger (or Make's custom webhook module) plus an email action does the whole job without code: the tool gives you a catch URL, you point the event source at it, map fields into an email template, and you are done in ten minutes. The tradeoffs are per-task pricing that scales with volume, limited control over retries and dedupe, and templates that live in a dashboard instead of version control. For a low-volume internal alert, that trade is often fine; for anything customer-facing, own the code.

Part 2: Receive an email as a webhook

Now flip the arrow. Instead of a webhook triggering an email, an email triggers a webhook: someone sends a message to an address, and your API receives it as a JSON POST. This is how you build reply-by-email, support inboxes that create tickets, document-ingestion addresses, email-driven bots — anything where email is the input.

Why this direction is the hard one

Sending email from code is a solved, one-API-call problem. Receiving it is not, because to accept email for an address you nominally need to be a mail server: publish MX records for a domain, run an SMTP listener that speaks the protocol to every mail server on the internet, survive spam and malformed traffic, and then parse what arrives. And what arrives is MIME — a decades-old, nested, inconsistently-implemented format where the plain-text body, the HTML body, inline images, and attachments are tangled together in multipart trees, with several competing encodings and header quirks that vary by sending client. Getting from “raw RFC 5322 message” to “the sender, the subject, the body as a string” is genuinely unpleasant work, and running the infrastructure behind it is a permanent operational tax.

An email-to-webhook service exists to delete that entire layer. InboxBridge runs the mail infrastructure, does the parsing, and hands your application the one thing it wanted all along: a clean JSON object at a URL you choose. Setup is about five minutes:

Step 1: Create a bridge

A bridge connects an inbound email address to your webhook URL. Sign up (the free plan covers 1,000 emails a month, no card required), create a bridge, and you get a unique address like abc123@webhook.inboxbridge.io. Point the bridge at your endpoint — say https://your-app.com/webhooks/inbound-email — and every message sent to that address is parsed and POSTed there. Each bridge also gets a signing secret, used to sign every delivery.

Step 2: Know the payload

When an email arrives, your endpoint receives JSON in this shape — addresses already split into email and name, bodies already separated into text and HTML, attachments already described:

{
  "id": "9b2c1f7a4e",
  "receivedAt": "2026-07-12T15:04:22.000Z",
  "from": { "email": "jordan@acme.com", "name": "Jordan Lee" },
  "to": [{ "email": "abc123@webhook.inboxbridge.io", "name": "" }],
  "cc": [],
  "bcc": [],
  "replyTo": null,
  "subject": "Question about invoice #1234",
  "date": "2026-07-12T15:04:00.000Z",
  "textBody": "Hi, can someone look into invoice #1234?",
  "htmlBody": "<p>Hi, can someone look into invoice #1234?</p>",
  "attachments": []
}

Step 3: Handle it

The handler mirrors the outbound one from Part 1, with the signature verification you already know how to write. InboxBridge signs each delivery with an HMAC-SHA256 of the raw body, sent in the X-InboxBridge-Signature header as sha256=…:

import crypto from "crypto";

app.post(
  "/webhooks/inbound-email",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.headers["x-inboxbridge-signature"] ?? "";
    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", process.env.INBOXBRIDGE_SIGNING_SECRET)
        .update(req.body)
        .digest("hex");

    const a = Buffer.from(String(signature));
    const b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const email = JSON.parse(req.body.toString("utf8"));

    console.log(`Email from ${email.from.email}: ${email.subject}`);
    // Create a ticket, post to Slack, kick off a job — it's just JSON now.

    res.status(200).json({ ok: true });
  }
);

Return a 2xx quickly. If your endpoint is down or returns an error, InboxBridge retries the delivery with exponential backoff and records every attempt — its status code and response time — in a delivery log you can inspect. The same idempotency thinking from Part 1 applies in this direction too: every payload carries a stable id you can dedupe on if a double-delivery would matter.

That is the whole setup. For the step-by-step version with delivery logs, testing, and local-development tips, see the inbound email webhook tutorial, or the email to webhook overview for what the service does end to end.

The two directions, side by side

Webhook → Email (outbound)Email → Webhook (inbound)
TriggerAn event in another system (payment, deploy, form submission) POSTs to your endpointA person or system sends an email to an address you hand out
ToolingAn HTTP endpoint plus a transactional email API (Resend, SendGrid, Postmark, SES) — or Zapier/Make for no-codeAn email-to-webhook service (InboxBridge) plus an HTTP endpoint — or MX records, an SMTP server, and a MIME parser if you insist
Hard partIdempotency and retries so duplicate deliveries never double-sendReceiving and parsing mail at all — which the service does for you
Use casesOrder confirmations, payment receipts, deploy/CI alerts, form notifications, digest emailsReply-by-email, support ticket creation, document ingestion, email-driven bots, forwarding mail into Slack or a database

Plenty of real systems need both: a support tool receives customer email as a webhook, creates a ticket, and then sends the agent's reply back out through an email API. Once both directions are just HTTP, composing them is ordinary application code.

FAQ

Can a webhook send an email?

Not by itself — a webhook is just an HTTP POST to a URL. To send an email from a webhook, you point the webhook at an endpoint you control (an Express route, a serverless function, or a no-code tool like Zapier), and that endpoint calls an email API such as Resend, SendGrid, Postmark, or Amazon SES. The webhook is the trigger; the email API does the sending.

How do I get an email into a webhook?

Use an inbound email parsing service. InboxBridge gives you a unique email address; any message sent to it is parsed into structured JSON (sender, subject, text and HTML bodies, attachments) and POSTed to your webhook URL, with signed requests and automatic retries. You never touch MX records, SMTP, or MIME parsing.

Do I need my own mail server?

No, for either direction. For sending, transactional email APIs handle SMTP, deliverability, SPF/DKIM, and bounce processing for you. For receiving, an email-to-webhook service like InboxBridge runs the mail infrastructure and hands your application clean JSON over HTTP. Running your own mail server is almost never worth the operational cost.

Related