Guide
Relay Inbound Email to a Webhook
Your service wants email as a webhook, but mail still arrives over IMAP. The bridge is usually a paid inbound-parse provider that charges per message. The CLI hands you each message as JSON; a few lines of shell POST it to your endpoint with an HMAC signature so the receiver can trust it. This guide builds an email-to-webhook relay you control, fired by a message.created trigger or a polling search loop, with no per-message fee.
Written by Hazik Director of Product Management
Reviewed by Qasim Muhammad
Command references used in this guide: nylas email search, nylas webhook create, and nylas webhook verify.
What does relaying email to a webhook mean?
Relaying email to a webhook means forwarding each inbound message to an HTTP endpoint as a JSON payload, so your service receives mail the same way it receives any other event. Instead of polling an IMAP mailbox, your receiver gets a POST per message and replies with a 2xx status to acknowledge it.
Two pieces make the relay. The CLI turns a provider mailbox into structured JSON across Gmail, Outlook, Yahoo, iCloud, and any IMAP host, so you write one relay instead of one parser per provider. Your shell then signs and ships that JSON. Inbound-parse vendors typically bill per message once you pass a free tier of a few hundred; a relay you run yourself has no per-message fee. The first step is getting a single message as JSON.
# Pull the most recent inbound messages as JSON
nylas email search "*" --in INBOX --after 2026-06-09 --json --limit 25 > inbox.jsonHow do I relay each message with a search loop?
Relay with a search loop by reading the JSON array one message at a time and POSTing each object to your endpoint. The nylas email search command returns an array of message objects; piping it through jq -c '.[]' emits one compact JSON line per message, which a while read loop forwards with curl. A 25-message batch relays in a couple of seconds.
Each POST sends the raw message object as the request body and sets a JSON content type. Send the message id in a header too, so the receiver can de-duplicate on it. The loop below is the core relay; the next section adds the signature that lets the receiver trust the request came from you and not a stranger who guessed the URL.
ENDPOINT="https://your-service.example.com/inbound"
jq -c '.[]' inbox.json | while read -r msg; do
msg_id=$(echo "$msg" | jq -r '.id')
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "X-Message-Id: $msg_id" \
--data-binary "$msg"
doneHow do I sign the webhook with HMAC?
Sign the webhook by computing an HMAC-SHA256 of the exact request body with a shared secret, then sending it in an X-Signature header. The receiver recomputes the same HMAC over the body it received and compares — if the values match, the payload is authentic and untampered. HMAC is defined in RFC 2104 and uses the SHA-256 hash from RFC 6234.
The signing rule that matters: sign the byte-for-byte body you actually send, and verify against the byte-for-byte body received. Reformatting or re-encoding the JSON between sign and verify breaks the comparison, because a single changed byte produces a completely different 256-bit digest. Compute the hash with openssl, prefix it with sha256= by convention, and attach it as a header on the same curl call.
WEBHOOK_SECRET="$RELAY_SECRET" # 32+ random bytes, shared with the receiver
jq -c '.[]' inbox.json | while read -r msg; do
msg_id=$(echo "$msg" | jq -r '.id')
sig=$(printf '%s' "$msg" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -s -X POST "$ENDPOINT" \
-H "Content-Type: application/json" \
-H "X-Message-Id: $msg_id" \
-H "X-Signature: sha256=$sig" \
--data-binary "$msg"
doneHow do I trigger the relay in real time?
Trigger the relay in real time by registering a message.created webhook with the CLI instead of polling on a timer. Nylas then notifies your endpoint within seconds of a message landing, rather than on the next cron tick. Register it once with nylas webhook create, passing your public URL and the message.created trigger.
Nylas signs its own notifications, so your receiver verifies the X-Nylas-Signature header before acting. The nylas webhook verify command checks a captured payload against that signature and your webhook secret, which is the fastest way to confirm your verification logic matches before you wire it into a live handler. Nylas's notification contract is documented in the Nylas notifications reference.
# Register a real-time trigger for new mail
nylas webhook create \
--url https://your-service.example.com/inbound \
--triggers message.created \
--description "Inbound email relay"
# Confirm your verification logic against a captured payload
nylas webhook verify \
--payload-file event.json \
--signature "$X_NYLAS_SIGNATURE" \
--secret "$NYLAS_WEBHOOK_SECRET"Why do retries need idempotency?
Retries need idempotency because webhook delivery is at-least-once, not exactly-once. If your endpoint is slow or returns a 5xx, the sender retries, and the same message can arrive two or three times. Without a de-duplication key, each retry creates a duplicate record downstream — the open loop from the TL;DR.
Solve it with the message id the relay already sends in X-Message-Id. Have the receiver treat that ID as a unique key: on a repeat ID, return 200 without re-processing. This is why a relay should respond fast and acknowledge before doing slow work — both Gmail's push notifications and Microsoft Graph subscriptions expect a quick 2xx and will retry on timeout, as described in the Gmail API push docs and the Microsoft Graph subscription reference. A relay that does its slow work asynchronously stays under the retry timeout.
Next steps
- Parse inbound email webhooks — verify and unpack Nylas notifications on the receiving end
- Receive inbound email from the CLI — capture mail locally before relaying it
- Send email to Slack — relay into a Slack channel instead of a custom endpoint
- Index email in Elasticsearch — relay the same JSON into a search index
- Store email in SQLite — persist relayed messages to a local database
- Full command reference — every flag and subcommand documented