Webhooks
A webhook is an HTTPS endpoint of yours that the mail service calls when something happens in your account's inboxes: a message arrives, or an inbox expires. Use it when you cannot keep a WebSocket open, for example in a server that processes sign-up emails.
Webhooks are a premium feature: a premium account can register up to 20 endpoints. Other tiers get 403 tier_required. Webhooks are managed from a logged-in session, not with an API key. Only inboxes owned by the account trigger webhooks; inboxes of an anonymous session never do.
Event types
- Name
message.new- Description
A message was stored.
datahas the same fields as the WebSocketnew_messageevent.
- Name
inbox.expired- Description
An inbox expired and its mail was removed.
dataisinbox_idandexpires_at.
- Name
webhook.test- Description
Sent only by
POST /v1/webhooks/:id/test.dataiswebhook_id. You cannot subscribe to it.
Delivery
Each delivery is an HTTPS POST with Content-Type: application/json and User-Agent: grrla-webhooks/1, and these headers:
- Name
X-Grrla-Event- Type
- string
- Description
The event name, for example
message.new.
- Name
X-Grrla-Delivery- Type
- string
- Description
Unique id of this delivery. It stays the same across retries, so use it to ignore duplicates.
- Name
X-Grrla-Signature- Type
- string
- Description
t=<unix seconds>,v1=<hex>. See verifying signatures.
The body has four fields: id (the delivery id), event, created_at (when the event happened, RFC 3339) and data.
message.new delivery
{
"id": "019a2f61-4a7b-7c90-a1b2-c3d4e5f60718",
"event": "message.new",
"created_at": "2026-10-26T08:05:12.481+00:00",
"data": {
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"message_id": "019a2f61-3e5a-7b12-8c4d-6e8f0a2b4c6d",
"received_at": "2026-10-26T08:05:12+00:00",
"from_addr": "noreply@example.com",
"from_name": "Example",
"subject": "Your sign-in code",
"snippet": "Your code is 481920. It expires in 10 minutes.",
"size_bytes": 5821,
"has_attachments": false
}
}
inbox.expired delivery
{
"id": "019a2f9c-0d1e-7f20-b3c4-d5e6f7081920",
"event": "inbox.expired",
"created_at": "2026-10-26T09:00:30.002+00:00",
"data": {
"inbox_id": "019a2f50-1c3d-7e41-8b2a-4f6d8e0a1c3b",
"expires_at": "2026-10-26T09:00:00+00:00"
}
}
A webhook body carries only the summary. Fetch the message itself with GET /v1/messages/:id, using a session token or an API key.
Retries and auto-disable
- Any
2xxresponse acknowledges the delivery. Anything else, including a timeout, is a failure. - Each attempt has a 10-second budget. Redirects are not followed and count as failures.
- A failed delivery is retried with exponential backoff (5 s, 10 s, 20 s, and so on), up to 8 attempts in total.
- After 20 consecutive failures the endpoint is disabled:
enabledbecomesfalseanddisabled_reasonsays why. Deliveries stop until you callPOST /v1/webhooks/:id/enable. - The URL must use
https, must not contain credentials or a fragment, and must resolve to public addresses. Private, loopback, link-local and similar ranges are refused: at registration for a literal IP orlocalhost, and after DNS resolution at every delivery. The connection is pinned to the addresses that passed the check. last_attempt_at,last_statusandlast_erroron the endpoint show the result of the latest attempt.
Verifying signatures
When you create a webhook, the response contains its secret once, as 64 hex characters. The signature is
v1 = hex(HMAC-SHA256(key, "<t>.<raw body>"))
where key is the hex-decoded secret (32 bytes) and t is the timestamp from the same header. To verify a delivery:
- Parse
tandv1fromX-Grrla-Signature. - Reject the delivery if
tis more than a few minutes from your clock. - Compute the HMAC over the raw request bytes, exactly as received. Do not parse and re-serialise the JSON first.
- Compare with
v1in constant time.
Each retry is signed again with a new t.
Verify X-Grrla-Signature
import hashlib
import hmac
import time
def verify(secret_hex: str, header: str, body: bytes, tolerance_secs: int = 300) -> bool:
try:
fields = dict(part.split("=", 1) for part in header.split(","))
t = int(fields["t"])
received = fields["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_secs:
return False
key = bytes.fromhex(secret_hex)
expected = hmac.new(key, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Flask example
# @app.post("/hooks/mail")
# def hook():
# if not verify(SECRET, request.headers.get("X-Grrla-Signature", ""), request.get_data()):
# abort(401)
# event = request.get_json()
# ...
# return "", 204
The webhook model
- Name
id- Type
- string
- Description
Webhook id.
- Name
url- Type
- string
- Description
Endpoint URL.
- Name
events- Type
- array
- Description
Subscribed events:
message.new,inbox.expired.
- Name
inbox_id- Type
- string | null
- Description
If set, only events for this inbox are delivered. Otherwise events for all of the account's inboxes.
- Name
enabled- Type
- boolean
- Description
Whether deliveries are being made.
- Name
failures- Type
- integer
- Description
Consecutive failed attempts.
- Name
created_at- Type
- timestamp
- Description
Creation time.
- Name
last_attempt_at- Type
- timestamp | null
- Description
Time of the last attempt.
- Name
last_status- Type
- integer | null
- Description
HTTP status of the last attempt,
nullif no response arrived.
- Name
last_error- Type
- string | null
- Description
Error of the last attempt, including the start of your response body for a non-2xx status.
- Name
disabled_reason- Type
- string | null
- Description
Why the endpoint was disabled.
The secret is never returned after creation.
Create a webhook
Registers an endpoint. Returns 201 with the signing secret, shown only in this response, and the webhook.
400 bad_request: the URL is refused,eventsis invalid, orinbox_idis not one of your inboxes.402 payment_required: you are at your webhook limit.403 tier_required: your tier has no webhooks.
Required attributes
- Name
url- Type
- string
- Description
HTTPS endpoint.
Optional attributes
- Name
events- Type
- array
- Description
A non-empty subset of
message.newandinbox.expired. Both when omitted.
- Name
inbox_id- Type
- string
- Description
Deliver only this inbox's events. It must be owned by the account.
Response
- Name
secret- Type
- string
- Description
Hex HMAC key for
X-Grrla-Signature. Store it now.
- Name
webhook- Type
- object
- Description
The webhook.
Request
curl https://mail.nullnet.sh/v1/webhooks \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/mail", "events": ["message.new"]}'
Response
{
"secret": "9f2c4e6a8b0d1f3e5a7c9b1d3f5e7a9c0b2d4f6e8a1c3e5b7d9f0a2c4e6b8d0f",
"webhook": {
"id": "019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6",
"url": "https://example.com/hooks/mail",
"events": ["message.new"],
"inbox_id": null,
"enabled": true,
"failures": 0,
"created_at": "2026-10-26T08:00:00Z",
"last_attempt_at": null,
"last_status": null,
"last_error": null,
"disabled_reason": null
}
}
List webhooks
Returns the account's webhooks with their delivery state, as an array.
Request
curl https://mail.nullnet.sh/v1/webhooks \
-H "Authorization: Bearer {token}"
Get a webhook
Returns one webhook.
Request
curl https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6 \
-H "Authorization: Bearer {token}"
Delete a webhook
Removes the webhook. Returns 204.
Request
curl -X DELETE https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6 \
-H "Authorization: Bearer {token}"
Send a test delivery
Queues a webhook.test delivery to the endpoint. Returns 202 with the delivery id to look for in your logs. The result appears on the webhook as last_status and last_error.
Request
curl -X POST https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6/test \
-H "Authorization: Bearer {token}"
Response
{ "delivery_id": "019a2f71-5c6d-7e8f-90a1-b2c3d4e5f607" }
Re-enable a webhook
Turns deliveries back on after the endpoint was disabled, and resets failures to 0. Returns the webhook.
Request
curl -X POST https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6/enable \
-H "Authorization: Bearer {token}"