Skip to main content

Forward Email to Webhooks

An inbox can POST every email it receives, as JSON, to URLs you configure, in the way SendGrid Inbound Parse or Postmark inbound processing work. Use it to build and test features that receive email: reply-by-email, support tickets from email, parsing receipts or forwarded documents.

Each delivery is signed with Standard Webhooks headers, retried when your endpoint fails, and logged so you can see what your endpoint answered.

Forwarding needs a paid plan. Pro allows 3 forwards per inbox; Production and Scale have no limit.

Add a forward​

From the dashboard​

  1. Open the inbox under Email
  2. In Forwarding, click Add webhook
  3. Enter the Webhook URL, and optionally the filters To (exact address), From contains and Subject contains
  4. Click Add webhook, then reveal and copy the forward's signing secret

Each forward shows its latest delivery. Send test, Deliveries, Edit, Pause and Delete sit next to it.

From the API​

curl -X POST https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/forwards \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/inbound-email", "filters": {"subject_contains": "Re:"}}'

The response includes the forward's signing_secret (whsec_…). See the Email Forwards API.

From an AI agent​

The MCP tool set_inbox_forward creates a forward (or updates one when given forward_id) and returns its signing secret; delete_inbox_forward removes it. list_inboxes lists each inbox's forwards.

Filters​

Filters are optional. When set, an email is forwarded only if all of them match:

FilterMatches
toExact recipient, case-insensitive. Use it with a plus-address, such as [email protected]
fromCase-insensitive substring of the From header
subject_containsCase-insensitive substring of the subject

With several forwards and different to filters, one inbox can feed several handlers.

What your endpoint receives​

POST /inbound-email HTTP/1.1
Content-Type: application/json
User-Agent: Hooklistener-Email-Forward/1.0 (+https://hooklistener.com)
webhook-id: msg_b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10
webhook-timestamp: 1791799331
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
"type": "email.received",
"id": "msg_b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10",
"created_at": "2026-10-12T10:02:11Z",
"data": {
"id": "b72d4c1e-6a0f-4a51-9f3e-2d8c1b7e9a10",
"inbox_id": "5c1d0e9a-3f7b-4f0e-9c1a-1b2e3f4a5b6c",
"from": "Jane Doe <[email protected]>",
"recipients": ["[email protected]"],
"subject": "Re: Ticket #1042",
"message_id": "<[email protected]>",
"size_bytes": 4127,
"has_text_body": true,
"has_html_body": true,
"created_at": "2026-10-12T10:02:11Z",
"text_body": "Thanks, that fixed it.\n\nOn Mon, Acme Support wrote: ...",
"html_body": "<p>Thanks, that fixed it.</p>...",
"text": "Thanks, that fixed it.\n\nOn Mon, Acme Support wrote: ...",
"headers": {
"from": "Jane Doe <[email protected]>",
"subject": "Re: Ticket #1042",
"in-reply-to": "<[email protected]>"
},
"header_list": [
{ "name": "From", "value": "Jane Doe <[email protected]>" },
{ "name": "Subject", "value": "Re: Ticket #1042" },
{ "name": "In-Reply-To", "value": "<[email protected]>" }
],
"links": [],
"codes": [],
"code": null,
"verification_link": null,
"attachments": []
}
}

data is exactly what GET /api/v1/inboxes/:id/emails/:email_id returns for the email. Attachments are listed with their metadata; download their bytes from download_url with an API key.

HeaderValue
webhook-idmsg_<email id>. The same email always has the same id, redeliveries included, so deduplicate on it. Test sends use msg_test_…
webhook-timestampUnix time of the attempt, in seconds
webhook-signaturev1,<base64 HMAC-SHA256> of <webhook-id>.<webhook-timestamp>.<raw body>

Verify the signature​

Standard Webhooks libraries verify these headers with the whsec_ secret. Without a library, sign <webhook-id>.<webhook-timestamp>.<raw body> with HMAC-SHA256, keyed with the base64-decoded part of the secret after whsec_, and compare it to the base64 value after v1,. Always use the raw request body, before any JSON parsing.

import crypto from "node:crypto";

function verifyHooklistenerEmail(secret, headers, rawBody) {
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(signed).digest("base64");
const fresh = Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"])) < 300;
return fresh && headers["webhook-signature"].split(" ").some((entry) => {
const [version, signature] = entry.split(",");
return version === "v1" && signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
}
import base64, hashlib, hmac, time

def verify_hooklistener_email(secret: str, headers, raw_body: bytes) -> bool:
key = base64.b64decode(secret.removeprefix("whsec_"))
webhook_id, timestamp = headers["webhook-id"], headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
version == "v1" and hmac.compare_digest(signature, expected)
for version, _, signature in (entry.partition(",") for entry in headers["webhook-signature"].split())
)

The five-minute freshness check rejects old deliveries replayed by someone else. Keep it in mind when you replay a captured delivery yourself later: the timestamp will be stale.

Retries and redelivery​

  • A 2xx answer within 10 seconds counts as delivered. Redirects are not followed.
  • Any other answer, a timeout or a network error is retried with exponential backoff (15 seconds, doubling), for up to 8 attempts over about 30 minutes.
  • A paused forward receives nothing. A delivery that was already queued when you paused it is logged as skipped.
  • Forward URLs must be public http(s) URLs. Private and local addresses are refused.

Each forward keeps a delivery log: status (pending, retrying, succeeded, failed or skipped), the HTTP status, duration, the first 1 KB of your endpoint's answer, and every attempt. Open it with Deliveries in the dashboard or GET /api/v1/inboxes/:id/forwards/:forward_id/deliveries.

To send an email again, use Redeliver on the email's page, or the API:

curl -X POST https://app.hooklistener.com/api/v1/inboxes/INBOX_ID/emails/EMAIL_ID/redeliver \
-H "Authorization: Bearer hklst_your_api_key" \
-H "Content-Type: application/json" \
-d '{"forward_id": "FORWARD_ID"}'

With forward_id, the email goes to that forward even if it's paused or its filters don't match. Without it, it goes to every enabled forward whose filters match. Redeliveries and test sends make a single attempt.

Send a test​

Send test in the dashboard, or POST /api/v1/inboxes/:id/forwards/:forward_id/test, sends the inbox's latest email to the forward, or a sample email when the inbox is empty. It works on a paused forward, so you can check a new handler before turning it on.

To exercise the whole path without sending real mail, store a message in the inbox with POST /api/v1/inboxes/:id/messages: it's forwarded like received mail.

Inspect deliveries and reach localhost​

Hooklistener can't deliver to localhost, and a handler that fails is hard to debug from the delivery log alone. Point the forward at a Hooklistener debug endpoint instead:

https://app.hooklistener.com/w/inbound-email-dev

Every delivery is then captured with its headers and body, where you can inspect it, compare it, or replay it. To have each delivery reach your local server as it arrives, run the CLI:

hooklistener listen inbound-email-dev --target http://localhost:3000/inbound-email

See Listen. Alternatively, open a tunnel with hooklistener tunnel --port 3000 and use its public URL as the forward URL, so deliveries go straight to your machine.

Limits​

FreeProProductionScale
Forwards per inbox—3UnlimitedUnlimited

Forwards count against your plan's forwarding destinations, per inbox. Inboxes without an account can't forward; claim the inbox first.