# Introduction ## Introduction Email-Webhook receives email on your behalf and immediately forwards it to an HTTP endpoint you control — sender, subject, body, and attachments delivered as a JSON payload, just like any other webhook. No IMAP polling. No MIME parsing. No persistent process to run. ### Where to go next - **[What is an Email Webhook?](/docs/what-is-an-email-webhook)** — how the model works, what the payload looks like, and when to use it instead of IMAP polling. - **[Getting Started](/docs/getting-started)** — sign up, create your first webhook, and receive a live delivery in five minutes. - **[Choosing an HTTP Method](/docs/http-methods)** — when to use POST vs GET and what changes between them. - **[Authenticating Your Endpoint](/docs/authentication)** — add a custom header so only email-webhook can call your endpoint. - **[Debugging with Message Logs](/docs/message-logs)** — trace individual deliveries and inspect request details. - **[Handling Attachments](/docs/handling-attachments)** — decode base64 attachments, upload them to S3, and handle large files safely. - **[Privacy and Data Retention](/docs/privacy-and-data)** — what we store and for how long. Guides: - **[AI and LLM Pipelines](/guides/ai-llm-pipelines)** — feed incoming email into a prompt and generate an AI-powered response. - **[AWS Lambda with SST](/guides/aws-lambda-sst)** — deploy a serverless webhook handler to AWS using API Gateway v2 and SST v3. - **[Email Webhook vs. IMAP Polling](/guides/email-webhook-vs-imap-polling)** — why a push-based webhook beats polling for most use cases. - **[Email Webhook vs. SendGrid Inbound Parse](/guides/email-webhook-vs-sendgrid-inbound-parse)** — how the two services compare on setup, payload format, and pricing. - **[Email Webhook vs. Postmark Inbound](/guides/email-webhook-vs-postmark-inbound)** — side-by-side comparison of features, latency, and cost. --- # What is an Email Webhook? ## What is an Email Webhook? A Developer's Guide Email has been around since the 1970s, yet consuming it programmatically is still surprisingly painful. If you've ever needed to react to an incoming email inside a web application — parse an order confirmation, open a support ticket, trigger an automation — you've probably run into the same wall: email doesn't push, it sits in a mailbox waiting to be polled. An **email webhook** solves this by flipping the model. Instead of your code asking "are there new emails?", the system calls your code the moment a message arrives, just like any other webhook. ### The core idea A webhook is simply an HTTP request that one system makes to another when something happens. You've used them before: Stripe calls your `/webhook` endpoint when a payment succeeds; GitHub calls it when someone pushes a commit. An email webhook extends that same pattern to email. When a message arrives at a particular address, the service immediately makes an HTTP `POST` (or `PUT`, `PATCH`, `DELETE`, or `GET`) to a URL you've configured. The email's contents (sender, subject, body, attachments) arrive as a JSON payload, ready for your code to handle. No polling loop. No IMAP connection to maintain. No MIME parsing on your side. ### What the payload looks like When email-webhook receives a message, it parses it and sends JSON like this to your endpoint: ```json { "from": "alice@example.com", "to": "abc123xyz@email-webhook.com", "subject": "New order #4821", "message": "Hi, I'd like to place an order for...", "attachments": [ { "filename": "order.pdf", "contentType": "application/pdf", "content": "JVBERi0xLjQK..." } ] } ``` The `message` field is the plain-text body of the email. Attachment contents are base64-encoded inline. Every request also carries an `X-email-webhook-id` header — a unique UUID for that invocation, useful for idempotency and debugging. ### A minimal receiving server Here's the smallest possible handler in a few common languages: **Node.js (Express)** ```javascript import express from "express"; const app = express(); app.use(express.json()); app.post("/webhook", (req, res) => { const { from, subject, message } = req.body; console.log(`New email from ${from}: ${subject}`); // your logic here res.sendStatus(200); }); app.listen(3000); ``` **Python (Flask)** ```python from flask import Flask, request app = Flask(__name__) @app.post("/webhook") def handle_email(): data = request.get_json() print(f"New email from {data['from']}: {data['subject']}") # your logic here return "", 200 ``` **Ruby (Sinatra)** ```ruby require "sinatra" require "json" post "/webhook" do data = JSON.parse(request.body.read) puts "New email from #{data['from']}: #{data['subject']}" # your logic here status 200 end ``` Return a `2xx` status and the delivery is recorded as successful. Anything else, and the invocation is marked as failed. ### Why not just poll an IMAP inbox? Polling works, but it comes with real costs: - **Latency.** A 5-minute poll interval means up to 5 minutes of delay on every event. - **Complexity.** You need to manage connection state, handle reconnects, track which messages you've already processed, and deal with IMAP's stateful protocol. - **Infrastructure.** A persistent polling process has to run somewhere, even when no emails are arriving. An email webhook handler, by contrast, is a single stateless HTTP endpoint. It can live in a serverless function, a Cloudflare Worker, or any existing web service. It only runs when there is work to do. ### Real-world use cases **Support ticket ingestion**: A customer emails your support address. The webhook fires, your handler creates a ticket in your database, and sends an acknowledgement. No support inbox to monitor. **Order confirmation parsing**: Your e-commerce platform sends order emails. The webhook fires, an LLM or simple parser extracts the order details, and your system records the purchase. **[Email-based AI agents](/docs/ai-llm-pipelines)**: A user sends a natural-language request to your app's email address. The webhook fires, you pass `message` to an LLM, and take action based on the response. **No-code automation pipelines**: Point the webhook URL at a Zapier Catch Hook, Make HTTP module, or n8n Webhook node to bridge incoming email into any downstream tool without writing a server. ## How email-webhook implements this When you sign up for [email-webhook](/sign-up), you get a unique `@email-webhook.com` address. You configure one or more webhooks, each with a target URL, HTTP method, optional sender filter, and optional custom headers for authentication. When mail arrives at your address, email-webhook's SMTP server receives it over an encrypted connection, matches it to the right webhook, and fires the HTTP request in real time. There's nothing to install, no API keys to provision for the sending side, and no persistent process to run. Your endpoint just needs to be reachable over HTTPS. Ready to try it? [Create your first webhook](/sign-up) in under a minute. --- # Getting Started ## Getting Started: Forward Your First Email to a Webhook in 5 Minutes This guide walks you from sign-up to your first webhook delivery. By the end you'll have a working pipeline: email in, HTTP request out. ### What you need - An email account (Gmail, Outlook, or anything else) - A publicly reachable URL — [webhook.site](https://webhook.site) gives you one instantly, with no sign-up required, and is perfect for this walkthrough ### Step 1: Sign up and copy your address [Sign up for email-webhook](/sign-up). Once you're logged in, your dashboard shows your unique incoming address: ``` abc123xyz@email-webhook.com ``` The local part is a system-generated opaque ID — every account gets a different one. **Treat it like a secret.** Anyone who can send mail to this address can trigger your webhooks. ### Step 2: Create a webhook Click **New webhook** in the dashboard and fill in the form: | Field | Value | |---|---| | **Webhook URL** | The URL that should receive requests (paste your webhook.site URL here) | | **HTTP method** | `POST` — the right default for receiving data | | **From email** | Leave blank to match mail from any sender | Leave **Enabled** checked and click **Create Webhook**. ### Step 3: Send a test email Open your email client and send a message to your `@email-webhook.com` address. Any subject and body will do: ```yml To: abc123xyz@email-webhook.com Subject: Hello from my inbox Body: This is my first email webhook test. ``` There's no "send test" button in the dashboard and no endpoint verification step — you test by sending a real email. ### Step 4: See the JSON arrive Within a few seconds your webhook URL receives a `POST` request. The body looks like this: ```json { "from": "you@yourdomain.com", "to": "abc123xyz@email-webhook.com", "subject": "Hello from my inbox", "message": "This is my first email webhook test.", "attachments": [] } ``` The request also carries these headers: | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `email-webhook/1.0` | | `X-email-webhook-id` | A UUID unique to this delivery | Save that `X-email-webhook-id` value. It lets you trace a specific delivery through your own logs and use it as an idempotency key if your handler could receive the same request more than once. ### What the payload fields mean | Field | Type | Description | |---|---|---| | `from` | string | Sender's email address | | `to` | string | Your `@email-webhook.com` address | | `subject` | string | The email subject line | | `message` | string | The plain-text body of the email. For HTML-only emails (no plain-text part), `message` will contain the HTML. | | `attachments` | array | Attached files, if any | If the email has attachments, each one appears as an object in the array: ```json { "filename": "receipt.pdf", "content": "JVBERi0xLjQK..." } ``` `content` is the raw attachment data, base64-encoded. ### A minimal receiving handler Once you've confirmed delivery in webhook.site, move to a real server. Here's the smallest possible handler in a few common languages: **Node.js (Express)** ```javascript import express from "express"; const app = express(); app.use(express.json()); app.post("/webhook", (req, res) => { const { from, subject, message } = req.body; console.log(`Mail from ${from}: ${subject}`); console.log(message); res.sendStatus(200); }); app.listen(3000); ``` **Python (Flask)** ```python from flask import Flask, request app = Flask(__name__) @app.post("/webhook") def handle_email(): data = request.get_json() print(f"Mail from {data['from']}: {data['subject']}") print(data["message"]) return "", 200 ``` **Ruby (Sinatra)** ```ruby require "sinatra" require "json" post "/webhook" do data = JSON.parse(request.body.read) puts "Mail from #{data['from']}: #{data['subject']}" puts data["message"] status 200 end ``` Return a `2xx` status and the delivery is recorded as successful. Any other status marks the delivery as failed. ### Next steps - **Lock it down**: [add a custom header](/docs/authentication) (e.g. `Authorization: Bearer `) to your webhook so only email-webhook can call your endpoint. - **Filter by sender**: set the **From email** field on your webhook to only fire for mail from a specific address — useful when you want to react to a particular service's notifications. - **Debug deliveries**: enable [Message Logs](/docs/message-logs) on your webhook to see the HTTP status code, duration, and metadata for each delivery. --- # Choosing an HTTP Method ## Choosing the Right HTTP Method for Your Webhook When you create a webhook you pick an HTTP method: GET, POST, PUT, PATCH, or DELETE. The method you choose changes both *where* the email data lands in the request and what your receiving endpoint needs to handle. ### How the method affects the request For **POST, PUT, PATCH, and DELETE** the email is sent as a JSON body — identical structure regardless of which of these four you pick: ```json { "from": "sender@example.com", "to": "abc123xyz@email-webhook.com", "subject": "Your order has shipped", "message": "Hi Jane, your order #4521 ...", "attachments": [...] } ``` For **GET** the data is URL-encoded into query parameters instead, and **attachments are not forwarded**: ``` https://your-endpoint.com/hook?from=sender%40example.com&to=abc123xyz%40email-webhook.com&subject=Your%20order%20has%20shipped&message=Hi%20Jane...&attachments= ``` The JSON payload structure is described in full in the [Getting Started](/docs/getting-started) guide. ### When to use each method #### POST — the right default Use POST when your handler creates a new record for each incoming email. It maps cleanly to REST convention and is the most common choice. ``` POST /tickets → create a new support ticket POST /events → append an event to a log POST /pipeline/trigger → kick off a processing job ``` #### PUT — upsert by identity Use PUT when you want to create-or-replace a resource whose identity you can derive from the email. A common pattern is keying on the sender address or a value parsed from the subject. ``` PUT /contacts/sender@example.com → create or update a contact record PUT /subscriptions/abc123 → set a subscription state ``` #### PATCH — partial update Use PATCH when the email signals that one field on an existing resource should change, rather than replacing the whole thing. ``` PATCH /orders/4521/status → update only the status field of an order ``` #### DELETE — removal workflows Use DELETE when the email is a signal to remove something — a cancellation email triggering record deletion, for example. ``` DELETE /subscriptions/abc123 → remove a subscription on cancellation notice ``` #### GET — simple integrations with no body Use GET when your endpoint can't consume a request body: some serverless platforms, simple scripts, or no-code tools expose URLs that only read query parameters. **Important limitations of GET:** - Attachments are **not forwarded** — the `attachments` parameter will be empty. - Long emails may exceed URL length limits (~2 KB in most environments) and get truncated or rejected. - No request body is sent, so frameworks that only parse body middleware won't see the data. For anything beyond a quick integration, prefer POST. ### Quick reference | Method | Data location | Attachments | Idiomatic use | |---|---|---|---| | POST | JSON body | ✓ included | Create / append | | PUT | JSON body | ✓ included | Upsert | | PATCH | JSON body | ✓ included | Partial update | | DELETE | JSON body | ✓ included | Trigger removal | | GET | Query parameters | ✗ excluded | Simple / no-body endpoints | ### Receiving the request Regardless of method, email-webhook always sends these headers: | Header | Value | |---|---| | `Content-Type` | `application/json` | | `User-Agent` | `email-webhook/1.0` | | `X-email-webhook-id` | UUID unique to this delivery | Your custom headers (e.g. `Authorization: Bearer `) are merged in on top. For guidance on using custom headers to authenticate your endpoint, see [How to Authenticate Your Webhook Endpoint](/docs/authentication). --- # Authenticating Your Endpoint ## How to Authenticate Your Webhook Endpoint When email-webhook fires your endpoint, the request comes from the internet. This guide explains the layers of protection available and how to validate them in your receiving server. ### Layer 1: Keep your email address secret Your incoming address — `abc123xyz@email-webhook.com` — is the first line of defence. The local part is a system-generated opaque ID, not a guessable name. Anyone who knows it can trigger your webhooks, so treat it like a password: don't commit it to public repos, don't paste it in public docs. If you suspect the address has leaked, delete your webhooks and create new ones to get a fresh address. ### Layer 2: Custom authentication headers The second layer is a secret header that your endpoint checks on every request. You add it once in the dashboard; email-webhook sends it with every delivery. **In the webhook form**, open the **Custom Headers** section and add a key-value pair: | Header name | Header value | | --------------- | -------------------------- | | `Authorization` | `Bearer your-secret-token` | Or equivalently with an API key header: | Header name | Header value | | ----------- | ------------------- | | `X-Api-Key` | `your-secret-token` | You can add up to 5 custom headers per webhook. #### Validating the header in your server **Node.js (Express)** ```javascript app.post("/webhook", (req, res) => { if (req.headers["authorization"] !== "Bearer your-secret-token") { return res.sendStatus(401); } const { from, subject, message } = req.body; // handle email... res.sendStatus(200); }); ``` **Python (Flask)** ```python @app.post("/webhook") def handle_email(): if request.headers.get("Authorization") != "Bearer your-secret-token": return "", 401 data = request.get_json() # handle email... return "", 200 ``` **Ruby (Sinatra)** ```ruby post "/webhook" do halt 401 unless request.env["HTTP_AUTHORIZATION"] == "Bearer your-secret-token" data = JSON.parse(request.body.read) # handle email... status 200 end ``` Return `401` (or any non-2xx status) and email-webhook records the delivery as failed. The request is not retried if the status code is `4xx`. ### Layer 3: HMAC signature verification The strongest layer is a signing secret. Unlike a header value, the secret is never sent over the wire — only a signature derived from it is. This proves both that the request came from email-webhook and that the body wasn't tampered with in transit. **Set it up:** open your webhook's settings and fill in the **Signing secret** field (new webhooks get one generated automatically; leave it blank to disable signing). When a secret is set, every non-GET delivery includes two extra headers: | Header | Contents | | ----------------- | -------------------------------------------------- | | `X-Timestamp` | Unix timestamp (seconds) when the request was sent | | `X-Signature-256` | `sha256=` | The signature is computed over the string `{timestamp}.{body}` — the `X-Timestamp` value, a literal `.`, and the raw JSON body — using your secret as the HMAC key. GET webhooks carry no body and are never signed. #### Verifying the signature in your server **Node.js (Express)** ```javascript import crypto from "node:crypto"; app.post("/webhook", express.raw({ type: "*/*" }), (req, res) => { const secret = "your-signing-secret"; const timestamp = req.headers["x-timestamp"]; const signature = req.headers["x-signature-256"]; const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(`${timestamp}.${req.body}`) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { return res.sendStatus(401); } if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.sendStatus(401); // reject stale/replayed requests } const { from, subject, message } = JSON.parse(req.body); // handle email... res.sendStatus(200); }); ``` **Python (Flask)** ```python import hmac, hashlib, time @app.post("/webhook") def handle_email(): secret = "your-signing-secret" timestamp = request.headers.get("X-Timestamp", "") signature = request.headers.get("X-Signature-256", "") signed = f"{timestamp}.{request.get_data(as_text=True)}" expected = "sha256=" + hmac.new( secret.encode(), signed.encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, signature): return "", 401 if abs(time.time() - int(timestamp)) > 300: return "", 401 # reject stale/replayed requests data = request.get_json() # handle email... return "", 200 ``` Always use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`) rather than `===`/`==`: a naive comparison leaks timing information an attacker can use to guess the signature byte by byte. The `X-Timestamp` freshness check is what makes this replay-resistant: even if an attacker captures a genuine request, replaying it later fails the age check. Five minutes is a reasonable window; tighten it if your endpoint is latency-sensitive. ### On bearer tokens Generally, prefer an API key over a bearer token. If you do need a bearer token, make sure it has a long enough duration (usually encoded as the `exp` parameter) so that your webhook requests succeed over time. You can learn more about bearer token at [https://jwt.io](https://jwt.io) ### Using `X-email-webhook-id` as an idempotency key Every delivery includes an `X-email-webhook-id` header containing a UUID unique to that request. Store it on the records you create and reject duplicates — this protects you if your server returns a non-2xx status but the processing already completed. ```javascript app.post("/webhook", async (req, res) => { if (req.headers["authorization"] !== "Bearer your-secret-token") { return res.sendStatus(401); } const deliveryId = req.headers["x-email-webhook-id"]; if (await alreadyProcessed(deliveryId)) { return res.sendStatus(200); // acknowledge without re-processing } // handle email... res.sendStatus(200); }); ``` --- # Debugging with Message Logs ## Enabling Message Logs: Debugging Your Email Webhooks When a webhook fires and nothing arrives at your endpoint, you need to know why. Message Logs give you per-delivery metadata — HTTP status, duration, sender, subject — without storing the content of your emails. Logging is **opt-in** and configured per webhook. It is off by default. ### Enabling logs on a webhook Open the webhook's edit page and expand the **Message Logs** section. Two options appear: | Setting | Options | Default | |---|---|---| | **Enable logs** | on / off | off | | **Retention** | 1 day, 7 days, 30 days | 7 days | Pick the shortest retention window that covers your typical debugging cycle. Log entries are automatically removed once they reach the retention age. Click **Save** — logging takes effect immediately for the next delivery. ### The log list Once logging is enabled, navigate to the webhook's **Logs** tab to see a table of recent deliveries. Each row shows: | Column | What it tells you | |---|---| | **Time** | When the email arrived | | **From** | The sender's address | | **Subject** | The email subject line (capped at 512 characters) | | **Attempts** | How many delivery attempts were made | | **Status** | A colour-coded badge summarising the outcome | #### Status badges | Badge | Colour | Meaning | |---|---|---| | `Pending` | Blue | Delivery is in progress | | `2xx` | Green | Your endpoint responded with a success status | | `4xx` | Amber | Your endpoint returned a client-error status (e.g. 401, 404) | | `5xx / Error` | Red | Your endpoint returned a server-error status, or the network request failed entirely | A network-level failure (DNS timeout, connection refused, TLS error) shows the error description in place of an HTTP status code. ### Filtering the log list Two filters help you zero in on the entries you care about: - **Status filter** — show only deliveries with a particular outcome: 2xx, 4xx, 5xx/network error, failed, or pending. - **Sender filter** — type any part of an email address; the list narrows to rows whose `From` address contains that string. Combine both filters to find, for example, every failed delivery from a particular sender. ### The log detail view Click any row to open the full delivery record: | Field | Description | |---|---| | **Received** | Exact timestamp the email arrived | | **From / To** | Sender and your `@email-webhook.com` address | | **Subject** | Email subject | | **Body size** | Size of the plain-text body, in bytes / KB / MB | | **Attachments** | Count and total size of attached files | | **Status** | HTTP status code and text, or the network error message | | **Final state** | `delivered`, `failed`, or `pending` | | **Attempts** | Delivery attempt count | | **Duration** | Round-trip time for the HTTP request, in milliseconds | This gives you enough to answer most debugging questions without touching the email content itself. ### Common failure patterns **4xx status (client error)** Your endpoint received the request and rejected it. The most frequent causes: - The URL path is wrong (404) - The custom `Authorization` header is missing or has an incorrect value (401 / 403) - Your application's router isn't handling the HTTP method you configured on the webhook (405) **5xx / network error** The request could not complete successfully. Check: - Is the server running and reachable from the public internet? (A local `localhost` URL will always fail.) - Did the application crash or time out before responding? - Is the TLS certificate valid? **Attempts > 1** Email-webhook makes a single delivery attempt per email. If you see a higher attempt count it means the same email address received multiple distinct emails — not that a failed delivery was retried automatically. ### Deleting logs To remove log data for a webhook, use the **Delete logs** action on the webhook's settings page. This removes **all** log entries for that webhook at once — there is no per-entry deletion. ### Privacy model Message Logs store only metadata. The bodies of your emails, attachment content, and your endpoint's HTTP response body are **never stored** — only the fields listed in the detail view above. This means you can leave logging enabled indefinitely on a shared or staging environment without accumulating sensitive content. For the full picture of what the service stores, see [Privacy and Data Retention](/docs/privacy-and-data). ### Next steps - If a delivery fails and you need to re-trigger it, send the original email again — there is no replay function in the logs UI. - To lock down your endpoint so that only email-webhook can call it, see [How to Authenticate Your Webhook Endpoint](/docs/authentication). - To restrict which senders can trigger a webhook at all, see the **From email** filter on the webhook settings page. --- # Handling Attachments ## Handling Email Attachments in Your Webhook When an email arrives with attachments, they are included inline in the JSON payload — base64-encoded, ready to decode. No separate request, no pre-uploaded URLs, no storage on our side. This guide shows how to work with them. ### The attachment object Each attachment appears as an object in the `attachments` array: ```json { "from": "alice@example.com", "to": "abc123@email-webhook.com", "subject": "Q1 report", "message": "See attached.", "attachments": [ { "filename": "q1-report.pdf", "content": "JVBERi0xLjQKJeLjz9MKNiAwIG9iag..." }, { "filename": "summary.xlsx", "content": "UEsDBBQABgAIAAAAIQDc..." } ] } ``` `content` is the raw file data, base64-encoded. `filename` is taken from the email's `Content-Disposition` header. If the sender's mail client did not set a filename, it defaults to `"unknown"`. When no attachments are present, `attachments` is an empty array — always safe to iterate without a null check. --- ### Decoding attachments #### Node.js **Save to disk:** ```javascript import fs from "fs/promises"; import path from "path"; app.post("/webhook", express.json(), async (req, res) => { const { attachments } = req.body; for (const attachment of attachments) { const buffer = Buffer.from(attachment.content, "base64"); const safeName = path.basename(attachment.filename); await fs.writeFile(`/tmp/${safeName}`, buffer); } res.sendStatus(200); }); ``` **Upload directly to S3:** ```javascript import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: "us-east-1" }); app.post("/webhook", express.json(), async (req, res) => { const { attachments } = req.body; for (const attachment of attachments) { const buffer = Buffer.from(attachment.content, "base64"); await s3.send(new PutObjectCommand({ Bucket: "my-bucket", Key: `attachments/${attachment.filename}`, Body: buffer, })); } res.sendStatus(200); }); ``` #### Python **Save to disk:** ```python import base64 import os from flask import Flask, request app = Flask(__name__) @app.post("/webhook") def handle_email(): data = request.get_json() for attachment in data.get("attachments", []): content = base64.b64decode(attachment["content"]) safe_name = os.path.basename(attachment["filename"]) with open(f"/tmp/{safe_name}", "wb") as f: f.write(content) return "", 200 ``` **Upload to S3 with boto3:** ```python import base64 import boto3 s3 = boto3.client("s3") @app.post("/webhook") def handle_email(): data = request.get_json() for attachment in data.get("attachments", []): content = base64.b64decode(attachment["content"]) s3.put_object( Bucket="my-bucket", Key=f"attachments/{attachment['filename']}", Body=content, ) return "", 200 ``` #### Go ```go package main import ( "encoding/base64" "encoding/json" "net/http" "os" "path/filepath" ) type Attachment struct { Filename string `json:"filename"` Content string `json:"content"` } type Payload struct { From string `json:"from"` Subject string `json:"subject"` Message string `json:"message"` Attachments []Attachment `json:"attachments"` } func webhookHandler(w http.ResponseWriter, r *http.Request) { var payload Payload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } for _, a := range payload.Attachments { data, err := base64.StdEncoding.DecodeString(a.Content) if err != nil { continue } safeName := filepath.Base(a.Filename) os.WriteFile("/tmp/"+safeName, data, 0644) } w.WriteHeader(http.StatusOK) } ``` --- ### Determining the file type The payload does not include a content-type field — only the filename. If your handler needs to branch on file type, derive it from the extension: ```javascript import path from "path"; import mime from "mime-types"; // npm install mime-types const ext = path.extname(attachment.filename).toLowerCase(); const contentType = mime.lookup(ext) || "application/octet-stream"; ``` ```python import mimetypes content_type, _ = mimetypes.guess_type(attachment["filename"]) content_type = content_type or "application/octet-stream" ``` --- ### Size considerations SMTP allows messages up to around 30 MB. Base64 encoding inflates binary data by roughly 33%, so a 10 MB file becomes approximately 13 MB in the JSON payload, and a 22 MB file approaches the practical ceiling. For handlers that process all attachments synchronously: - Small files (under ~5 MB each): decoding into memory and writing to disk or uploading to S3 is fine. - Larger files: return `200` immediately and do the heavy work asynchronously: ```javascript app.post("/webhook", express.json(), async (req, res) => { res.sendStatus(200); // acknowledge before processing setImmediate(() => processAttachments(req.body.attachments)); }); ``` For serverless functions with strict timeout limits (Cloudflare Workers at 10 s, Vercel at 30 s), this pattern is especially important. Decode, upload to cloud storage, and enqueue a job rather than processing the file inline. --- ### Validating filenames Filenames come from the sender's mail client and should be treated as untrusted input. Before writing to disk, always sanitise the name: ```javascript import path from "path"; // Strip directory components — prevents path traversal const safeName = path.basename(attachment.filename); ``` ```python import os safe_name = os.path.basename(attachment["filename"]) ``` Never use the raw filename as a filesystem path without stripping directory separators first. --- ### Next steps - To understand the full payload structure, see [Getting Started](/docs/getting-started). - To process attachments from AI-readable files (PDFs, images), see [AI and LLM Pipelines](/guides/ai-llm-pipelines). - To debug whether attachments arrived correctly, enable [Message Logs](/docs/message-logs) — they record the attachment count and total size per delivery without storing the content. --- # Privacy and Data Retention ## Privacy and Data Retention: What email-webhook Stores (and What It Doesn't) If you're routing business email through any third-party service, a fair question to ask is: *what exactly does this service see, and what does it keep?* This article answers that question for email-webhook, with specifics grounded in how the service actually works. ### The short version email-webhook is designed to be a pass-through, not an archive. When an email arrives, the service parses it and immediately fires an HTTP request to your endpoint. The email content is not written to disk at any point in that process. --- ### What IS stored #### Your account and webhook configuration Your account data (name, email address) and all webhook settings are stored persistently: | Data | Where | Deleted when | |---|---|---| | Account name and email | Main database | You delete your account | | Webhook URL, method, headers | Main database | You delete the webhook | | From-email filter setting | Main database | You delete the webhook | This is the data that makes the service work — without it, incoming mail can't be routed to the right endpoint. **A note on stored headers:** Custom headers you configure on a webhook (for example, an `Authorization: Bearer ` value) are stored in the webhook record. Treat these like passwords: rotate them if you suspect exposure. #### Message log metadata (opt-in only) If you enable [Message Logs](/docs/message-logs) on a webhook, the service records delivery metadata for each email that triggers it. This feature is **off by default**. When enabled, the following fields are stored per delivery: | Field | Example | |---|---| | Sender address | `orders@mystore.com` | | Recipient address | `abc123xyz@email-webhook.com` | | Subject line | `Order confirmed #4821` | | Body size | 2 048 bytes | | Attachment count and total size | 1 file, 45 KB | | HTTP status code and text | `200 OK` | | Delivery duration | 142 ms | | Final state | `delivered` / `failed` | | Timestamp | Unix milliseconds | Log data is stored in a separate per-user database, isolated from other accounts. --- ### What is NOT stored #### Email body content The plain-text (or HTML fallback) body of every email passes through memory during processing and is forwarded to your endpoint. It is never written to disk. Message Logs record the body's *size in bytes* — so you can see that a 14 KB message arrived — but the content itself is gone the moment the HTTP request is dispatched. #### Attachment content Attachments are base64-encoded and included in the JSON payload sent to your endpoint. Like the email body, they exist only in memory during transit. The attachment count and total size are recorded in Message Logs when logging is enabled, but the attachment data itself is never persisted. #### Your endpoint's HTTP response After your server responds, the response body is immediately discarded. Message Logs capture the HTTP status code and status text (e.g. `200 OK` or `404 Not Found`), but nothing from the response body. --- ### Comparison with IMAP-based email parsing services Many hosted email-parsing services work differently: they accept mail into a persistent mailbox, store the full message, and let you poll or query it later. That model means your email content — including body and attachments — is retained on their servers, often indefinitely or until an explicit delete. email-webhook has no mailbox. There is no stored copy of any message to query, replay, or export. If your endpoint is down when an email arrives, the delivery attempt is made once and the content is not buffered for retry. This is a deliberate trade-off: you get strong privacy guarantees and no data accumulation, at the cost of having no built-in replay mechanism. If a delivery fails, the fix is to ensure your endpoint is reachable and send the email again. --- ### Controlling your data #### Disabling Message Logs Turning off Message Logs on a webhook stops all future metadata collection immediately. Existing log entries remain until you delete them manually. #### Deleting log entries The **Delete logs** action on a webhook's settings page removes all log entries for that webhook at once. There is no per-entry deletion. #### Deleting a webhook Deleting a webhook removes its configuration from the database. Any associated log entries must be deleted separately before removing the webhook, or they will be cleaned up automatically when the webhook is gone. #### Deleting your account Deleting your account removes your webhook configurations and account data. Log databases are removed as part of account teardown. --- ### What we don't do - **No ad tracking or analytics on your email content.** We have no visibility into what your emails say. - **No selling or sharing of your data.** Webhook configurations and log metadata are yours. - **No compliance certifications** (SOC 2, ISO 27001, etc.) — if your compliance programme requires certified infrastructure, that requirement sits on your end, not ours. - **No data residency options** — the service runs on a single deployment. If regional data residency is a hard requirement, email-webhook is not the right tool. --- ### Next steps - To understand exactly what metadata Message Logs capture, see [Debugging with Message Logs](/docs/message-logs). - To lock down your endpoint so only email-webhook can call it, see [Authenticating Your Endpoint](/docs/authentication). - For the full legal data-handling policy, see the [Privacy Policy](/privacy). --- # AI and LLM Pipelines ## Using Email Webhooks for AI and LLM Pipelines An email address is a natural input interface for AI agents. Users already know how to write an email; you don't need to build a UI. This guide shows how to wire an incoming email straight into an LLM and do something useful with the result. ### The pattern ``` User sends email → SMTP receives it → webhook fires → your server calls LLM → result is stored or acted on ``` Your server is the integration layer. email-webhook delivers the parsed email as JSON; everything else — the LLM call, prompt construction, result handling — happens in your code. ### What the payload gives you When an email arrives, your endpoint receives a JSON body with these fields: | Field | What to use it for | |---|---| | `message` | The plain-text body — feed this directly to the LLM | | `from` | The sender's address — use as user identity | | `subject` | Often a good prompt prefix or task description | | `attachments` | Array of base64-encoded files, if any | The `message` field is plain text. For HTML-only emails (no plain-text part), it will contain the raw HTML — worth stripping tags before sending to an LLM. ### Minimal example: email → LLM → stored result **Node.js (Express)** ```javascript import express from "express"; import OpenAI from "openai"; const app = express(); app.use(express.json()); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); app.post("/ai-webhook", async (req, res) => { // Authenticate the request first — see the authentication guide if (req.headers["x-api-key"] !== process.env.WEBHOOK_SECRET) { return res.sendStatus(401); } const { from, subject, message } = req.body; const completion = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: "You are a helpful assistant. Reply concisely." }, { role: "user", content: `Subject: ${subject}\n\n${message}` }, ], }); const reply = completion.choices[0].message.content; // Store or forward the result — e.g. save to a database, call another API await saveToDatabase({ from, subject, reply }); res.sendStatus(200); }); app.listen(3000); ``` **Python (Flask)** ```python import os from flask import Flask, request from anthropic import Anthropic app = Flask(__name__) client = Anthropic() @app.post("/ai-webhook") def handle_email(): if request.headers.get("X-Api-Key") != os.environ["WEBHOOK_SECRET"]: return "", 401 data = request.get_json() prompt = f"Subject: {data['subject']}\n\n{data['message']}" message = client.messages.create( model="claude-haiku-4-5-20251001", max_tokens=1024, messages=[{"role": "user", "content": prompt}], ) reply = message.content[0].text save_to_database(sender=data["from"], reply=reply) return "", 200 ``` Return `200` and email-webhook records the delivery as successful. Any non-2xx response is a failure — the delivery will not be retried, so make sure your handler is robust before returning a success status. ### Restricting who can trigger the pipeline By default a webhook fires for mail from any sender. For an AI pipeline you usually want to restrict this — otherwise anyone who discovers your address can invoke the LLM on your bill. Set the **From email** field on your webhook to an exact address (e.g. `trusted@mycompany.com`). Mail from any other sender is silently discarded before your endpoint is ever called. See [Getting Started](/docs/getting-started) for details on the **From email** field. For a multi-user setup where you want to allow a known list of addresses, create one webhook per trusted sender — each pointing at the same endpoint URL — rather than trying to filter in application code. ### Secure the endpoint Always authenticate the incoming request with a custom header. Add an `X-Api-Key` or `Authorization` header in the webhook dashboard; validate it as the first thing your handler does. Details in the [authentication guide](/docs/authentication). The `X-email-webhook-id` header on every request is a UUID unique to that delivery. Store it alongside any records you create so you can detect and ignore duplicates safely. ### Handling attachments in AI pipelines If users send files (PDFs, images, CSVs), they arrive in the `attachments` array as base64-encoded strings. Decode them before passing to a model that accepts file input: ```javascript const { attachments } = req.body; for (const attachment of attachments) { const buffer = Buffer.from(attachment.content, "base64"); // Pass buffer.toString() or buffer to your LLM's file API } ``` For large attachments (emails can carry up to ~34 MB), decode to a stream and write to cloud storage rather than holding the whole file in memory before the LLM call. ### Keeping latency in check email-webhook makes a single synchronous HTTP call to your endpoint and waits for a response. LLM calls add latency — a few seconds is normal. That's fine: the webhook timeout is generous enough to accommodate a standard completion call. If you need to do heavier processing (multiple LLM calls, retrieval, tool use), return `200` immediately and do the work asynchronously in the background: ```javascript app.post("/ai-webhook", async (req, res) => { // Authenticate... res.sendStatus(200); // acknowledge immediately // Heavy work happens after the response is sent setImmediate(() => runPipeline(req.body)); }); ``` ### Next steps - **Lock down the sender**: set the **From email** filter so only trusted addresses trigger the pipeline. - **Add a custom header**: add an `Authorization` or `X-Api-Key` header in the webhook dashboard and validate it in your handler. - **Enable message logs**: turn on [Message Logs](/docs/message-logs) on your webhook to see status codes, durations, and delivery metadata while you're building. --- # AWS Lambda with SST ## Receiving email-webhook Requests in AWS Lambda with SST SST makes it easy to stand up an API Gateway v2 HTTP endpoint backed by a Lambda function. Once deployed, you paste the URL into the email-webhook dashboard and every incoming email lands in your function as a typed JSON payload. This guide uses **SST v3 (Ion)**, **API Gateway v2**, and **TypeScript**. ### Prerequisites - An [AWS account](https://aws.amazon.com) with credentials configured locally (`aws configure` or environment variables) - [Node.js](https://nodejs.org) 18+ - [SST CLI](https://ion.sst.dev/docs/reference/cli/) installed (`npm install -g sst`) --- ### Step 1: Create a new SST project ```bash mkdir email-handler && cd email-handler npx create-sst@latest ``` Choose the default **Empty** template when prompted. This creates: ``` email-handler/ ├── sst.config.ts ├── package.json └── src/ ``` --- ### Step 2: Define the API and function Replace the contents of `sst.config.ts`: ```typescript /// export default $config({ app(input) { return { name: "email-handler", // Non-production stages are torn down on `sst remove`; production resources are kept removal: input?.stage === "production" ? "retain" : "remove", home: "aws", }; }, async run() { // Stores the shared secret as an encrypted SST secret — set it once with // `npx sst secret set WebhookSecret ` and it's available at runtime // via the Resource binding without any manual env var wiring const secret = new sst.Secret("WebhookSecret"); // Creates an API Gateway v2 HTTP API (lower latency and cost than REST API) const api = new sst.aws.ApiGatewayV2("EmailApi"); // Routes POST /webhook to the handler function; linking the secret // automatically grants the function permission to read it at runtime api.route("POST /webhook", { handler: "src/handler.handler", link: [secret], }); return { url: api.url }; }, }); ``` --- ### Step 3: Write the handler Create `src/handler.ts`: ```typescript import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2, } from "aws-lambda"; import { Resource } from "sst"; interface Attachment { filename: string; content: string; // base64-encoded } interface EmailPayload { from: string; to: string; subject: string; message: string; attachments: Attachment[]; } export async function handler( event: APIGatewayProxyEventV2 ): Promise { // Verify the shared secret set in the email-webhook dashboard if (event.headers["x-my-api-key"] !== Resource.WebhookSecret.value) { return { statusCode: 401 }; } // Use X-email-webhook-id as an idempotency key const deliveryId = event.headers["x-email-webhook-id"]; const payload: EmailPayload = JSON.parse(event.body ?? "{}"); console.log(JSON.stringify({ deliveryId, from: payload.from, subject: payload.subject, attachments: payload.attachments.length, })); // Your logic here — store to a database, trigger a workflow, etc. return { statusCode: 200 }; } ``` The handler does three things before any business logic: 1. **Authenticates** the request using the `X-My-Api-Key` header. 2. **Reads the idempotency key** (`X-email-webhook-id`) so you can detect duplicate deliveries. 3. **Parses the typed payload** — no `any` casts needed. --- ### Step 4: Install types ```bash npm install --save-dev @types/aws-lambda ``` --- ### Step 5: Set the secret and deploy Set the secret value before the first deploy. Replace `my-secret-token` with a random string you generate (e.g. `openssl rand -hex 32`): ```bash npx sst secret set WebhookSecret my-secret-token ``` Then deploy: ```bash npx sst deploy --stage production ``` SST prints your endpoint URL when it finishes: ``` url: https://abc123.execute-api.us-east-1.amazonaws.com ``` Your webhook URL is: ``` https://abc123.execute-api.us-east-1.amazonaws.com/webhook ``` --- ### Step 6: Wire it up in the email-webhook dashboard 1. Open the [email-webhook dashboard](/dashboard) and click **New webhook** (or edit an existing one). 2. Set **Webhook URL** to your Lambda endpoint. 3. Set **HTTP Method** to `POST`. 4. Open the **Custom Headers** section and add: | Header name | Header value | | ---------------- | ------------------- | | `X-My-Api-Key` | `my-secret-token` | 5. Click **Save**. Send a test email to your `@email-webhook.com` address. Within a few seconds your Lambda function receives the request and logs the delivery. --- ### Handling attachments Attachments arrive base64-encoded inside the `attachments` array. To upload them to S3, add an S3 bucket to your SST config and link it to the function: ```typescript // sst.config.ts (inside run()) const bucket = new sst.aws.Bucket("Attachments"); api.route("POST /webhook", { handler: "src/handler.handler", link: [secret, bucket], }); return { url: api.url, bucket: bucket.name }; ``` Then in the handler: ```typescript import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; const s3 = new S3Client({}); for (const attachment of payload.attachments) { const buffer = Buffer.from(attachment.content, "base64"); await s3.send(new PutObjectCommand({ Bucket: Resource.Attachments.name, Key: `${deliveryId}/${attachment.filename}`, Body: buffer, })); } ``` SST automatically grants the Lambda function the necessary S3 permissions when you use `link`. --- ### Staying within Lambda timeouts API Gateway v2 has a hard 30-second integration timeout. If you have heavy processing to do (OCR, ML inference, large file uploads), acknowledge the request immediately and hand off to a queue: ```typescript import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; const sqs = new SQSClient({}); export async function handler(event: APIGatewayProxyEventV2) { // auth check ... await sqs.send(new SendMessageCommand({ QueueUrl: Resource.EmailQueue.url, MessageBody: event.body ?? "{}", MessageDeduplicationId: event.headers["x-email-webhook-id"], MessageGroupId: "email", })); return { statusCode: 200 }; } ``` Return `200` as soon as the message is enqueued. A second Lambda, subscribed to the queue, does the real work without any timeout pressure. --- ### Next steps - **Lock down your endpoint**: see [Authenticating Your Endpoint](/docs/authentication) for all supported header patterns. - **Debug deliveries**: enable [Message Logs](/docs/message-logs) to see HTTP status, duration, and payload metadata for each delivery. - **Decode attachments**: for deeper examples including type detection and safe filename handling, see [Handling Attachments](/docs/handling-attachments). --- # Email Webhook vs. SendGrid Inbound Parse ## email-webhook vs. SendGrid Inbound Parse: Which Should You Use? SendGrid Inbound Parse is the most widely known way to receive email over HTTP. email-webhook does the same job with considerably less ceremony. This article breaks down exactly where they differ so you can choose the right tool. --- ### How each approach works **SendGrid Inbound Parse** works through your own domain. You point an MX record at SendGrid's mail servers, configure an inbound parse domain in their dashboard, and SendGrid forwards arriving mail to a URL you control — as a `multipart/form-data` POST. **email-webhook** gives you a dedicated `@email-webhook.com` address the moment you sign up. You point that address wherever you like (forward from your own inbox, use it directly, set it as a reply-to), then configure a webhook URL in the dashboard. Incoming mail is forwarded as a JSON POST. --- ### Side-by-side comparison | | SendGrid Inbound Parse | email-webhook | | -------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------- | | **Setup** | MX record change + domain verification + parse domain config | Sign up and paste a URL | | **Payload format** | `multipart/form-data` | `application/json` | | **Attachments** | Binary multipart parts; count in `attachments`, metadata in `attachment-info` | Inline base64 array in the JSON body | | **Email content stored?** | Yes — passes through SendGrid's infrastructure | No — forwarded from memory, never written to disk | | **HTTP methods supported** | POST only | POST, PUT, PATCH, DELETE, GET | | **Custom headers** | Not configurable on the outgoing webhook request | Up to 5 per webhook | | **Own domain required** | Yes — you must control DNS for the receiving domain | No — you use a `@email-webhook.com` address out of the box | | **Primary product focus** | Email sending platform; inbound parse is a secondary feature | Purpose-built for receiving email over HTTP | --- ### The payload format gap This is the most practical difference day-to-day. SendGrid delivers mail as `multipart/form-data`. Your handler has to extract fields from a form post, not a JSON body — which means framework-specific parsing, extra middleware, and more boilerplate: ```javascript // SendGrid Inbound Parse handler — multipart form-data import multer from "multer"; const upload = multer(); app.post("/webhook", upload.any(), (req, res) => { const from = req.body.from; const subject = req.body.subject; const text = req.body.text; // `attachments` is a count; `attachment-info` is a JSON string with metadata. // Actual file data arrives as binary multipart parts, accessible via req.files. const attachmentCount = Number(req.body.attachments ?? 0); const attachmentInfo = JSON.parse(req.body["attachment-info"] ?? "{}"); const files = req.files; // array of multer file objects res.sendStatus(200); }); ``` email-webhook sends a plain JSON body. No form parser, no extra middleware: ```javascript // email-webhook handler — plain JSON app.post("/webhook", express.json(), (req, res) => { const { from, subject, message, attachments } = req.body; res.sendStatus(200); }); ``` Attachments are inline in the `attachments` array, each with a `filename` and base64-encoded `content`. No multipart parser, no separate binary parts to reassemble. --- ### Setup complexity Getting SendGrid Inbound Parse running requires: 1. A domain you control with access to its DNS settings. 2. Adding an MX record pointing at `mx.sendgrid.net` — which routes all mail for that subdomain through SendGrid. 3. Verifying the domain inside SendGrid's dashboard. 4. Configuring an "Inbound Parse" entry with the hostname and destination URL. 5. Waiting for DNS propagation. With email-webhook: 1. Sign up. 2. Create a webhook with your URL. 3. Send a test email to your `@email-webhook.com` address. No DNS changes. No domain required. No propagation wait. You can be receiving webhooks in under five minutes. --- ### Privacy and data handling SendGrid is part of Twilio's infrastructure. When a message hits their inbound parse pipeline, it passes through their systems before arriving at your endpoint. Your email content — bodies, attachments, sender details — transits and is processed on their servers. email-webhook is designed as a pass-through. When an email arrives, it is parsed in memory and immediately forwarded to your endpoint. The body and attachment content are **never written to disk**. If you have [Message Logs](/docs/message-logs) enabled, only metadata is stored (sender, subject line, HTTP status, duration) — never the email content itself. For teams routing business email, confidential notifications, or any privacy-sensitive messages through a third-party service, this distinction matters. --- ### When SendGrid Inbound Parse is the right choice - You are already using SendGrid for email sending and want a single vendor. - You need to receive email at an address on **your own domain** (e.g. `support@mycompany.com`) and can change its MX records. - You need the spam score, DKIM result, and SPF status that SendGrid includes in the payload. --- ### When email-webhook is the right choice - You want to receive webhook deliveries **without touching DNS**. - Your handler is built to consume JSON — you don't want to deal with `multipart/form-data` parsing. - You are building something where **email content must not be stored** by a third party. - You want to use POST, PUT, PATCH, DELETE, or GET and send custom authentication headers with every delivery. - You are building a serverless function, an AI pipeline, or any stateless HTTP handler and want the simplest possible integration. --- ### Using email-webhook with your own domain If you need to receive mail at your own domain address, you are not limited to the `@email-webhook.com` address. Most email providers — Gmail, Fastmail, Postfix — support forwarding rules. Set up a forwarding rule that sends mail from `support@mycompany.com` to your `@email-webhook.com` address, and your webhook fires on every delivery, no MX record change required. --- ### Summary Choose **SendGrid Inbound Parse** if you are already on SendGrid, need inbound mail on your own domain's MX record, or need the spam and authentication metadata it provides. Choose **email-webhook** if you want a simpler setup, a clean JSON payload, stronger privacy guarantees, and a service built specifically for this use case. [Get started in under five minutes →](/docs/getting-started) --- # Email Webhook vs. Postmark Inbound ## email-webhook vs. Postmark Inbound: Which Should You Use? Of all the inbound email services available, Postmark Inbound is the closest to email-webhook in design: both give you a dedicated email address without requiring DNS changes, and both deliver mail as JSON. The differences are subtler than those with SendGrid — but they matter. This article walks through them so you can pick the right tool. --- ### How each approach works **Postmark Inbound** is part of Postmark's transactional email platform. You create a server, configure an inbound stream, and Postmark gives you an `@inbound.postmarkapp.com` address (or lets you point your own domain at their MX records). Incoming mail is forwarded to your webhook URL as a JSON `POST`, authenticated with HTTP Basic Auth. **email-webhook** gives you a `@email-webhook.com` address the moment you sign up. You create a webhook with a URL, optional HTTP method, and any custom request headers. Incoming mail is forwarded as a JSON `POST` (or `PUT`, `PATCH`, `DELETE`, or `GET`) immediately. --- ### Side-by-side comparison | | Postmark Inbound | email-webhook | | -------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- | | **Payload format** | `application/json` | `application/json` | | **Attachments** | Inline base64 in JSON array | Inline base64 in JSON array | | **Authentication** | HTTP Basic Auth (username + password on your endpoint) | Custom headers — any name and value you choose | | **Setup** | Account → server → inbound stream → webhook URL | Sign up → create webhook | | **HTTP methods supported** | POST only | POST, PUT, PATCH, DELETE, GET | | **Custom request headers** | Not configurable | Up to 5 per webhook | | **Email content stored?** | Yes — visible in Postmark's activity feed | No — forwarded from memory, never written to disk | | **Own domain required?** | No — `@inbound.postmarkapp.com` provided; own domain optional | No — `@email-webhook.com` provided | | **Primary product focus** | Transactional email sending; inbound is a secondary feature | Purpose-built for receiving email over HTTP | --- ### Authentication: custom headers beat Basic Auth This is the most meaningful practical difference. Postmark authenticates webhook requests using HTTP Basic Auth — a username and password you set on your endpoint. That means your server needs to decode the `Authorization: Basic ` header, split out the credentials, and compare them. Every framework handles this a little differently, and Basic Auth credentials are easy to accidentally log or expose in proxy configurations. email-webhook lets you attach any custom header to every outgoing request. You set a header name and value in the dashboard; email-webhook sends it with every delivery: ```http POST /webhook HTTP/1.1 X-Api-Key: your-secret-token Content-Type: application/json ``` Your handler validates it with a single string comparison: ```javascript if (req.headers["x-api-key"] !== process.env.WEBHOOK_SECRET) { return res.sendStatus(401); } ``` You can use any header convention your stack already knows — `Authorization: Bearer`, `X-Api-Key`, `X-Webhook-Secret`, or anything else. You can add up to 5 headers per webhook, which makes it straightforward to forward different secrets to different downstream services without changing your endpoint logic. Rotating the secret is a dashboard update — no credential file to reissue, no Basic Auth configuration to rebuild. --- ### Payload: lean versus rich Both services deliver JSON, but Postmark's payload carries substantially more fields: ```json { "From": "alice@example.com", "FromName": "Alice", "To": "abc123@inbound.postmarkapp.com", "ToFull": [...], "Cc": "", "CcFull": [...], "Bcc": "", "BccFull": [...], "OriginalRecipient": "abc123@inbound.postmarkapp.com", "ReplyTo": "", "MailboxHash": "", "Subject": "Hello", "Date": "Thu, 5 Apr 2012 16:59:01 +0200", "TextBody": "Hello!", "HtmlBody": "...", "StrippedTextReply": "", "Tag": "", "MessageID": "...", "Headers": [...], "Attachments": [...] } ``` email-webhook delivers exactly what most handlers need: ```json { "from": "alice@example.com", "to": "abc123@email-webhook.com", "subject": "Hello", "message": "Hello!", "attachments": [...] } ``` If you genuinely need CC lists, raw headers, or the HTML body alongside the plain-text body, Postmark's richer payload is useful. For the majority of integrations — support ticket ingestion, automation triggers, AI pipelines — you only ever read `from`, `subject`, and the message body. email-webhook gives you those without requiring you to navigate a larger schema or distinguish between `TextBody` and `HtmlBody`. --- ### Setup: fewer concepts to learn Postmark's inbound feature is built around their server and stream model. To get a working address you need to: 1. Create a Postmark account. 2. Create a **server** (Postmark's top-level organisational unit). 3. Navigate to the server's **inbound stream**. 4. Set a **webhook URL** on the stream. 5. Optionally configure spam filtering thresholds. Each of these is a separate step in a dashboard designed primarily around sending email. Postmark's concepts — servers, streams, message streams — make sense in the context of a full email platform, but they add friction if all you want is to receive email over HTTP. With email-webhook: 1. Sign up. 2. Create a webhook with a URL. 3. Send a test email. No server abstraction. No stream configuration. No platform concepts to understand before you can receive your first delivery. --- ### Privacy and data handling Postmark stores incoming messages in its activity feed, visible in the dashboard, for a retention period tied to your plan. That means email content — bodies, subjects, sender addresses — is written to Postmark's infrastructure and queryable after delivery. email-webhook is a pass-through: mail arrives, is parsed in memory, and is immediately forwarded to your endpoint. The body and attachment content are **never written to disk**. If you enable [Message Logs](/docs/message-logs), only delivery metadata is stored (sender address, subject line, HTTP status, duration) — never the email content itself. For applications that process business email, personally identifiable information, or confidential notifications, this is a meaningful distinction. --- ### When Postmark Inbound is the right choice - You are already using Postmark for transactional sending and want a single vendor. - You need the richer payload — CC/BCC lists, raw headers, HTML body — and don't want to parse it yourself. - Your stack already handles HTTP Basic Auth cleanly. --- ### When email-webhook is the right choice - You want a simpler setup with no platform concepts to navigate. - You prefer modern header-based authentication over Basic Auth. - You need a clean, minimal JSON payload without schema noise. - You want to use PUT, PATCH, DELETE, or GET in addition to POST. - You need email content to remain private and never be stored by a third party. - You are building something purpose-driven — a serverless function, an AI pipeline, an automation trigger — and want a tool that does exactly one thing well. --- ### Summary If you're already on Postmark and its richer payload or single-vendor convenience matters to you, Postmark Inbound is a reasonable choice. If you're starting fresh and want the simplest possible path from incoming email to an HTTP call — with modern authentication, a minimal payload, and no email content retained anywhere — email-webhook is the better fit. [Get started in under five minutes →](/docs/getting-started) --- # Email Webhook vs. IMAP Polling ## Email Webhook vs. Polling an IMAP Inbox: Which Should You Use? There are two mainstream approaches to consuming incoming email programmatically: **IMAP polling** and **email webhooks**. Both get the job done, but they have very different operational profiles. This article breaks down the trade-offs so you can pick the right tool for your situation. --- ### How each approach works **IMAP polling** connects to a mail server using the IMAP protocol, checks for new messages on a schedule, fetches them, and marks them as processed. Your code drives the loop. **Email webhooks** flip the model entirely. You give your application a dedicated email address — in this case, a `@email-webhook.com` address — and configure a URL to receive an HTTP request every time a message arrives. The service drives the delivery; your code just handles it. --- ### Side-by-side comparison | | IMAP polling | Email webhook | | ------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------- | | **Delivery model** | Pull — your process asks for new mail | Push — mail is delivered to your endpoint | | **Latency** | Up to your poll interval (seconds to minutes) | Real-time — fires as soon as mail is received | | **Credentials needed** | Yes — IMAP username + password (or OAuth token) | No — the email address itself is the shared secret | | **Persistent process required** | Yes — a loop must run continuously | No — your endpoint only runs when invoked | | **Works with serverless** | Rarely — needs long-running infrastructure | Yes — a single HTTP handler is enough | | **Protocol complexity** | High — IMAP is stateful and verbose | Low — your code sees a plain JSON POST | | **MIME parsing** | On you — raw messages need parsing | Done for you — `from`, `subject`, `message`, `attachments` are ready to use | | **Debugging** | Wherever you put your logs | [Message logs](/docs/message-logs) per webhook, with HTTP status codes and timing | | **Works with any mailbox** | Yes — Gmail, Outlook, self-hosted, etc. | Requires forwarding to the webhook address | --- ### When IMAP polling is the right choice Polling makes sense when you need to consume mail from an **existing mailbox you don't control** — for example, a shared Gmail inbox that also receives human mail. If you cannot change where the email is delivered, IMAP is your only option. It also works well for low-volume, low-urgency tasks where a few minutes of latency is acceptable, and where you're already running persistent infrastructure (a cron job, a long-running server) that absorbs the overhead. **Typical fit:** Legacy integrations, inboxes owned by a third party, situations where you need to read historical mail in bulk. --- ### When an email webhook is the right choice A webhook is the better default for **new integrations where you control the email address**. The operational surface is dramatically smaller: - No credentials to rotate. - No connection state to manage or recover from. - No polling logic to tune or drift. - No dedicated infrastructure — a Cloudflare Worker, an AWS Lambda, or any existing HTTP server is enough. Because the handler is stateless and only runs on demand, it fits naturally into serverless architectures. It also responds faster — email-webhook fires the HTTP request as soon as the SMTP transaction completes, so latency is measured in seconds, not minutes. **Typical fit:** New pipelines, serverless workloads, AI agents, automation triggers, support ticket ingestion, anything event-driven. --- ### Deliverability considerations Both approaches depend on mail actually arriving at the destination address. The key difference is visibility. With IMAP polling you only know mail arrived when you fetch it. With email-webhook you can enable [Message Logs](/docs/message-logs) on each webhook and see exactly what was received — sender, subject, HTTP status code, response time, and whether the delivery succeeded — without capturing full message bodies. --- ### Locking down access IMAP uses a password (or OAuth token) to gate access to the mailbox. A webhook uses a different model: the email address itself is a long, opaque, unguessable ID — so anyone without the address can't trigger your webhooks in the first place. You can layer additional security on the HTTP side by adding a custom `Authorization` header to each request; see [Authenticating Your Endpoint](/docs/authentication) for the details. You can also restrict a webhook to fire only when mail arrives from a specific sender address. That's useful when you want to process notifications from a known service while silently discarding everything else. --- ### Practical migration path If you're currently polling an IMAP inbox and want to switch: 1. Set up a forwarding rule in your mail client or server to forward incoming mail to your `@email-webhook.com` address. 2. Create a webhook in the dashboard pointing at your existing handler URL. 3. Update your handler to read from the JSON payload (`from`, `subject`, `message`) instead of parsing raw MIME. 4. Enable [Message Logs](/docs/message-logs) during the transition to verify deliveries are landing. Once you're satisfied, you can decommission the polling process entirely. --- ### Summary Choose **IMAP polling** when you need to read from an existing mailbox you don't control, or when latency is not a concern and you're already running persistent infrastructure. Choose **an email webhook** when you're building something new, want real-time delivery, care about operational simplicity, or need to run in a serverless environment. [Get started in under five minutes →](/docs/getting-started)