A webhook sends each new message for one of your addresses to your own server, as a signed POST with the message's plain text, so your app, Zapier or n8n can act on it. imado keeps the message in the mailbox as always; the webhook is a copy of what it says, not a move.
Who can set it up
A project admin who reads the mailbox. Everyone else who reads it is told by email when a webhook is added or its URL changes, and sees it in Mail → Settings → This mailbox, with its name, the host it calls and who added it.
A webhook belongs to the project, like the mailbox itself: it keeps running when the person who set it up leaves the team, and any admin who reads the mailbox can change or remove it.
Add a webhook
- In Setup, open the project's Addresses and then the mailbox.
- Under Where new mail goes, open the list. The first time, pick Webhook; once something is there, press Add destination and choose Webhook.
- Choose the address whose mail it receives, and type your endpoint's URL. A name is optional: everyone who reads the mailbox sees it, so call it what it is, such as "Order system".
- Press Add and send a test.
We send your endpoint a test event straight away and show you the signing secret, once. Copy it then: it is never shown again, though you can make a new one. Nothing else is sent until your endpoint answers a test with a 2xx. If it hasn't within seven days of the last test, the webhook is removed.
The URL must be a public https:// address on the standard port. We never call an IP address, another port, or a name that points into a private network, and we don't follow redirects.
What your server receives
One POST per new message, with content-type: application/json:
{
"type": "message.received",
"timestamp": "2026-11-02T09:00:00.000Z",
"data": {
"message_id": "…",
"thread_id": "…",
"address": "support@example.com",
"received_at": "2026-11-02T08:59:58.000Z",
"from": {
"address": "jane@example.net",
"name": "Jane Doe"
},
"to": [
"support@example.com"
],
"cc": [],
"reply_to": [
"jane@example.net"
],
"subject": "Refund for order 1042",
"subject_truncated": false,
"recipients_truncated": false,
"text": "Hi, I was charged twice…",
"text_truncated": false,
"attachments": [
{
"filename": "receipt.pdf",
"content_type": "application/pdf",
"size": 48213
}
],
"attachments_truncated": false,
"authentication": {
"spf": "PASS",
"dkim": "PASS",
"dmarc": "PASS"
},
"url": "https://www.imado.cloud/mail/…"
}
}
textis the message as plain text. There is no HTML, no attachment file and no raw header: openurlto read the message in imado.from.nameisnullunless the sender's domain vouches for it, so a stranger can't pass as someone you know.authenticationcarries the sender's SPF, DKIM and DMARC results for your code to judge.- The whole body is at most 64 KB. A long subject, a long list of recipients or attachments, or a long text is cut to fit, and the matching
_truncatedfield says so.
The test event is smaller: "type": "reaction.test" and only the address in data. Answer it with a 2xx.
Check the signature
Every call is signed the Standard Webhooks way, so any of its libraries checks it. Three headers carry it:
webhook-id: the event's id. It is the same on every try of one event, so you can tell a repeat.webhook-timestamp: when this try was sent, in seconds since 1970.webhook-signature:v1,and a base64 HMAC-SHA256 ofid.timestamp.body, keyed by the 32 bytes afterwhsec_in your secret. For 24 hours after you make a new secret there are two, separated by a space; accept either.
Check it against the raw body, before parsing it, and refuse a timestamp more than five minutes away from your clock.
Node
import { createHmac, timingSafeEqual } from "node:crypto"
export function verified(secret, headers, rawBody) {
const id = headers["webhook-id"]
const timestamp = headers["webhook-timestamp"]
const signatures = headers["webhook-signature"]
if (!id || !timestamp || !signatures) return false
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const key = Buffer.from(secret.slice("whsec_".length), "base64")
const expected = createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest()
return signatures.split(" ").some((one) => {
const [version, value] = one.split(",")
if (version !== "v1" || !value) return false
const given = Buffer.from(value, "base64")
return given.length === expected.length && timingSafeEqual(given, expected)
})
}
Python
import base64, hashlib, hmac, time
def verified(secret: str, headers, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
for one in signatures.split(" "):
version, _, value = one.partition(",")
if version == "v1" and hmac.compare_digest(value.encode(), expected):
return True
return False
Go
func verified(secret string, h http.Header, rawBody []byte) bool {
id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
sent, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 {
return false
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(rawBody)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, one := range strings.Split(h.Get("webhook-signature"), " ") {
version, value, ok := strings.Cut(one, ",")
if ok && version == "v1" && hmac.Equal([]byte(value), []byte(expected)) {
return true
}
}
return false
}
What is sent, and what isn't
New mail that arrives for that address once the webhook has answered a test. Mail our scan marked as spam or a virus, and mail whose scan did not come back clean, is never sent, whichever folder it is in. It's the scan that decides, not the folder: a clean reply in a conversation you moved to Spam is still sent. Mail that was already in the mailbox isn't sent either.
Webhooks send no mail, so an unauthenticated message is still sent to your endpoint, with its authentication results for your code to judge.
Answers and retries
We wait up to 10 seconds for an answer.
- A
2xx: sent. 408,429or a5xx, or no connection at all: we try again, 5 minutes later, then waiting twice as long each time up to an hour, and never sooner than aRetry-Afteryou send. Every try carries the samewebhook-id. After 24 hours without a2xx, the event is given up.410: your endpoint says it no longer exists. The webhook pauses.- Any other answer, a redirect included: refused, and not sent again.
- No answer after the connection was made: the event may have arrived, so it is never sent again. Setup shows it as No answer, not sent again.
Events can arrive out of order. Use received_at to order them and webhook-id to drop a repeat.
Setup shows every call of the last 90 days on the webhook's Calls: when, its event id, what happened, each try and its status code. It never shows the sender or the subject: they stay in the mailbox.
When a webhook pauses
imado pauses a webhook rather than keep calling a server that can't take the mail, or send mail round a loop. Setup says why, and what starts it again:
- Your endpoint answered that it no longer exists. Send a test from the webhook's page once it's back, or change its URL.
- Your endpoint hasn't taken new mail for a day. Press Resume once it's fixed.
- More mail arrived in an hour than may be sent, or most of the last hour's mail had the same subject. Either can mean a loop: check that what your endpoint does doesn't send mail back to the address, then press Resume.
Mail that arrives while a webhook is paused stays in imado and isn't sent to it later. The project's admins who read the mailbox are emailed when a webhook pauses, at most once a day for each webhook.
Fair use lists how many places one address can send its mail to, and how many calls an hour.
Where calls come from
Calls come from imado's servers, whose IP addresses change, so a list of addresses to allow won't hold. Check the signature instead. Every call carries user-agent: imado-webhooks/1 (+https://www.imado.cloud/docs/webhooks).
Change or remove a webhook
Open it from Where new mail goes:
- Settings → Endpoint URL: a new URL is tested at once, and nothing is sent until it answers with a
2xx. Your secret stays the same. - Settings → Signing secret → Make a new secret: the new one is shown once, and both sign every event for 24 hours, so you can switch without missing any. Making another within those 24 hours replaces the newest one, which stops signing at once, while the one you started with still signs until its 24 hours end. So if a new secret was never shown to you, make another.
- Settings → Remove webhook: mail stops going there at once. The mailbox keeps every message.