Turn on new-mail alerts
New-mail webhooks
postkeep can POST a small JSON event to a URL of your choice when new
mail arrives in your Inbox. Webhooks are opt-in per account: sign in at
/account on your server and fill in the “New-mail webhook” card. “Send
test event” saves the form and sends one sample event right away.
Operators can turn the feature off with POSTKEEP_WEBHOOKS=false.
Watchers only run while the server does, so platforms that stop idle
instances must keep one running; see
deployment.
How new mail is detected
| Account | How postkeep learns about new mail |
|---|---|
| iCloud, Fastmail, Yahoo, custom IMAP | An IMAP IDLE connection to the Inbox, or a 60-second poll when the server has no IDLE |
| Outlook (Microsoft) | A Microsoft Graph change-notification subscription; Graph calls POST /push/microsoft (and /push/microsoft/lifecycle) on your server |
| Gmail (Google) | Gmail users.watch through Google Cloud Pub/Sub; a push subscription calls POST /push/google on your server |
Outlook and Gmail need sign-in with that provider configured, and Gmail
additionally needs a Pub/Sub topic and push subscription; see
the Outlook and Gmail guides. A
notification from Microsoft or Google is only a hint. postkeep then reads
mail added since the last event it recorded, and retries of one event keep
the same X-Webhook-Id. Graph does not promise to deliver every
notification, so postkeep also reads the Outlook Inbox every 15 minutes
without one. If the stored Gmail history has expired or paging
is cut off, postkeep lists at most one page of 50 Inbox messages and then
moves to the current history id, so older messages past that page are not
reported. An IMAP backlog is reported oldest first as several events of at
most 50 messages each, before the watcher goes back to waiting for new mail.
A gap in X-Webhook-Sequence means events were dropped.
An Outlook or Gmail catch-up that fails because the provider or the database
is briefly unavailable (a timeout, a transport error, HTTP 408, 429 or 5xx,
a token refresh that may succeed later, or any database error) is retried
with a doubling backoff, from 5 seconds up to 5 minutes. Retries stop on
shutdown, when the user has turned the webhook off, when it is missing or
its settings can’t be read, when the account is removed or no longer
allowed, when the sign-in is no longer valid, and on any other failure. A
pause after failed deliveries does not stop this catch-up, and it does not
stop renewal: the watcher keeps its slot, Gmail users.watch and the Graph
subscription keep renewing on their usual schedule, and new mail is
recorded. A pause does not call the provider more often. Mail recorded
while paused is not delivered before the next pause retry. A new
notification during a failed catch-up runs the next read immediately.
Turning the webhook off stops the watcher and the provider watch.
Request
POST /your/path HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: postkeep/<version>
X-Webhook-Id: 0b4e5c1e-3f7d-4f0e-9a43-5d7c0c8e2a11
X-Webhook-Event: new_mail
X-Webhook-Timestamp: 1791432724
X-Signature-256: sha256=5d0c…
Authorization: Bearer … (only if you set an auth header)
{"event":"new_mail","account":"[email protected]","folder":"INBOX",
"id":"imap.4211.SU5CT1g","uid":4211,"message_id":"<[email protected]>","from":"Alice <[email protected]>",
"subject":"Lunch?","date":"2026-10-08T02:12:04+00:00","count":1}Payload
| Field | Type | Description |
|---|---|---|
event |
string | Always new_mail. |
test |
boolean | true on test events sent from the account page (sample data). Absent otherwise. |
account |
string | The account’s email address. |
folder |
string | The folder, currently always INBOX. |
id |
string | Opaque id of the newest new message, the same id the MCP tools take. |
uid |
integer | IMAP UID of the newest new message. Only present for IMAP accounts; prefer id. |
message_id |
string or null | Its Message-ID header. |
from |
string | Its From header. Only present when “Include sender and subject” is on. |
subject |
string | Its subject. Only present when “Include sender and subject” is on. |
date |
string or null | Its Date header, RFC 3339. |
count |
integer | How many new messages this event covers (at most 50 for an IMAP event, an Outlook page, and a Gmail resync). An IMAP backlog larger than 50 is several events in a row, oldest first, reported before the watcher waits for more mail. A Gmail history catch-up can cover more than 50 in one event. Messages arriving within a few seconds of each other are batched into one event describing the newest of that batch. An Outlook burst larger than one page of 50 is several such events in a row, reported without waiting for another notification. |
“Include sender and subject” is applied when the event is sent. Turning it
off removes from and subject from events already queued. Turning it on
does not add them to events that were recorded without them.
The event never contains message bodies. Unknown fields may be added later; ignore them.
Headers
| Header | Description |
|---|---|
X-Webhook-Id |
Unique per event and the same across retries. Use it to drop duplicates. |
X-Webhook-Sequence |
The event’s number in the account’s stream, starting at 1 and increasing by one per event. A gap means events were dropped (for example after the webhook was re-enabled); test deliveries have none. |
X-Webhook-Event |
new_mail. |
X-Webhook-Timestamp |
Unix time (seconds) the event was signed. |
X-Signature-256 |
sha256= and the lowercase hex HMAC-SHA256, if signing is on. |
| your auth header | The name and value you entered, such as Authorization: Bearer …. Reserved names (Host, Content-Type, X-Webhook-*, …) can’t be used. |
Verifying the signature
The signature is HMAC-SHA256, keyed with the whole signing secret (including
the whsec_ prefix), over the timestamp, a dot and the raw body:
signature = hex(HMAC_SHA256(secret, "{X-Webhook-Timestamp}.{raw body}"))Compare in constant time against the X-Signature-256 value without its
sha256= prefix, and reject timestamps more than five minutes away from
your clock to limit replays. Always verify the raw bytes you received, not
re-serialized JSON.
Python
import hashlib, hmac, time
def verify(secret: str, headers, body: bytes) -> bool:
timestamp = headers["X-Webhook-Timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
received = headers.get("X-Signature-256", "").removeprefix("sha256=")
return hmac.compare_digest(expected, received)Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody) {
const timestamp = headers["x-webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
const received = Buffer.from((headers["x-signature-256"] || "").replace(/^sha256=/, ""), "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Rotating the secret on the account page invalidates the old one at once.
Delivery and retries
- Answer with any
2xxstatus within 10 seconds. The response body is ignored. - Network errors, timeouts,
5xx,408and429are retried after 2 s, 10 s and 60 s (four attempts in total). - Redirects are not followed and count as a failure, as do other
4xxstatuses. Point the webhook at the final URL.
A webhook is active (enabled and delivering), paused (enabled, backing
off after failures), or disabled (the user turned it off). It can also be
unreadable (saved, but no configured key can decrypt it), not configured,
or unavailable when the operator set POSTKEEP_WEBHOOKS=false.
- While paused, the oldest pending event is tried about once an hour
(
POSTKEEP_WEBHOOK_RETRY_INTERVAL, 3600 seconds by default, from 1 to 86400). The account page shows the last error, the last success, and the next retry time. New mail does not start an extra attempt on every message. The watcher keeps running and recording into the same queue, so mail that arrives during the outage is delivered after recovery. The account keeps itsPOSTKEEP_WEBHOOK_MAX_WATCHERSslot. IMAP keeps its connection. Gmailusers.watchand the Graph subscription keep renewing on their usual schedule (about once a day, or a day before the Graph subscription expires). A pause does not shorten that schedule or call the provider more often. A push while paused records the new mail and does not deliver it before the next slot. If nothing is pending, nothing is probed; the next recorded event is tried at the next slot. A restart, saving the settings, turning it back on, a successful test, or signing in again may try once immediately. After that attempt the hourly cadence resumes. A test does not count toward the 10 failures, and a test success does not clear the pause. The first successful real delivery clears the pause, resets the failure count, and the rest of the backlog is then sent promptly, in order, with the originalX-Webhook-IdandX-Webhook-Sequence. Turning the webhook off on the account page, or withset_webhook_enabled, stops the watcher promptly and frees the slot. Gmail is stopped withusers.stop. The Graph subscription is deleted; if that delete fails, the local record is kept and the next time the webhook is turned on the subscription is renewed or deleted instead of duplicated. There is then no watcher, no renewal, and no automatic recovery. Repeated failures never turn a webhook off. - Every event is stored (encrypted) before it is sent and numbered per
account; the number is sent as
X-Webhook-Sequence. An event that still fails after the short retries is kept and sent again later, first after a minute and then with a doubling delay capped at the same interval (one hour by default), and the events after it wait so the receiver always gets them in order. By default events are kept for 7 days, and at most 1000 per account. Both limits count every kept event, delivered or not: a delivered event stays so it can be sent again. Each account can raise them, up to 30 days and 10000 events, withset_webhook_outbox_limits. The account page shows the effective limits and does not edit them.POSTKEEP_WEBHOOK_OUTBOX_RETENTION(604800 seconds) andPOSTKEEP_WEBHOOK_OUTBOX_MAX_EVENTS(1000) are the server defaults.POSTKEEP_WEBHOOK_OUTBOX_MAX_RETENTION(2592000 seconds, 30 days) andPOSTKEEP_WEBHOOK_OUTBOX_MAX_EVENTS_LIMIT(10000) are ceilings an account cannot pass. The shortest retention is 1 hour and the smallest cap is 1 event. An event is a small encrypted JSON payload, about 1 KB on disk, so the default is about 1 MB per account and the ceiling about 10 MB. One hundred accounts at the ceiling is about 1 GB. The event cap is what protects the disk: a busy inbox reaches it before the retention clock does. Thirty days covers a receiver that stays down for a few weeks while the hourly pause retry is still running. Lowering a limit deletes the oldest kept events immediately, including ones that were not delivered yet.last_sequence,delivered_sequenceand the watch cursor do not move backwards, so a gap inX-Webhook-Sequenceis an event that was dropped. If an operator later lowers a ceiling, a stored override above it is limited to the new ceiling when events are pruned, and the status still reports that value as an account override. Setting a value outside the current range is rejected and the error names the allowed range. The stored value stays as it was.
- Events kept while the user had turned the webhook off are dropped when it is turned on again, and when its URL changes, so a new receiver only sees new mail. The cursor survives. Events kept while it was only paused are kept when the account page is saved with the same URL still enabled: that save clears the pause and retries them promptly.
- A server upgraded from a version that turned webhooks off after repeated
failures pauses those webhooks instead and starts watching again from the
cursor they already had. Mail that arrived in the Inbox since then is
reported with the same batching as any other catch-up, still bounded by
that account’s outbox limits (7 days and 1000 events by default, up to
30 days and 10000). An IMAP backlog is several events of at
most 50 messages, oldest first. An Outlook catch-up is one event per page
of at most 50. A Gmail account whose stored history id is still valid
reports every Inbox addition in that history as one event, so
countcan be higher than 50 (100 additions is one event withcount100). If that Gmail history has expired, or history paging is cut off, Gmail lists at most one page of 50 Inbox messages and then moves to the current history id, so older messages past that page are not reported. A webhook the user turned off stays off. - Existing mail is never reported: watching starts at the newest message
when the webhook is first enabled, and again if the Inbox’s
UIDVALIDITYchanges.
get_webhook_status and
set_webhook_enabled
report and turn delivery on or off for the signed-in account.
set_webhook_outbox_limits
sets how long that account keeps events and how many. Omit a field to
leave it unchanged. JSON null or the string default stores the server
default for that field. reset true stores the server default for both
and ignores the other fields. retention is a whole number of seconds or
a duration such as 36h, 30d or 90m. A value below 1 hour or 1 event,
or above the server’s ceiling, is rejected and the error names the
allowed range. The stored value stays as it was. Lowering a limit deletes
the oldest kept events immediately, including ones that were not
delivered, and the result includes events_pruned (how many were deleted)
plus the same status fields as get_webhook_status. The tool needs a
webhook saved on the account page (/account). It works while the webhook
is paused or turned off. The account page shows the effective limits and
does not edit them. All three tools return the URL’s host only, never the
full URL, the signing secret, the auth header, or any ciphertext, and
they cannot change the endpoint or its secrets. Set those on the account
page. When the saved settings can’t be decrypted, the status is
unreadable and its message asks for the URL and auth header to be
entered again on the account page. The host, the last error and the pause
schedule are left out, because they cannot be checked without those
secrets. The outbox limits are still reported: those columns are stored
in the clear. Turning that webhook on is refused, because the watcher
cannot use it; turning it off still works. set_webhook_outbox_limits
still works on an unreadable row.
Which URLs are allowed
To keep the server from being used to reach internal systems (SSRF):
- The URL must be
https, with a certificate valid for its host, and must not contain a user name or password. - The host must not be, or resolve to, a private, loopback, link-local,
carrier-grade NAT, multicast, documentation or reserved address. Cloud
metadata (
169.254.169.254,fd00:ec2::254), unique local IPv6 (such as Fly.io’sfdaa::/16private network) and IPv6 forms embedding such IPv4 addresses are refused too. If any address a name resolves to is blocked, the name is refused. - Names like
localhost,*.local,*.internaland single-label names are refused. - The connection goes to the address that was checked, so a second DNS answer can’t redirect it.