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. data has the same fields as the WebSocket new_message event.

  • Name
    inbox.expired
    Description

    An inbox expired and its mail was removed. data is inbox_id and expires_at.

  • Name
    webhook.test
    Description

    Sent only by POST /v1/webhooks/:id/test. data is webhook_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 2xx response 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: enabled becomes false and disabled_reason says why. Deliveries stop until you call POST /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 or localhost, and after DNS resolution at every delivery. The connection is pinned to the addresses that passed the check.
  • last_attempt_at, last_status and last_error on 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:

  1. Parse t and v1 from X-Grrla-Signature.
  2. Reject the delivery if t is more than a few minutes from your clock.
  3. Compute the HMAC over the raw request bytes, exactly as received. Do not parse and re-serialise the JSON first.
  4. Compare with v1 in 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, null if 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.


POST/v1/webhooks

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, events is invalid, or inbox_id is 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.new and inbox.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

POST
/v1/webhooks
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
  }
}

GET/v1/webhooks

List webhooks

Returns the account's webhooks with their delivery state, as an array.

Request

GET
/v1/webhooks
curl https://mail.nullnet.sh/v1/webhooks \
  -H "Authorization: Bearer {token}"

GET/v1/webhooks/:id

Get a webhook

Returns one webhook.

Request

GET
/v1/webhooks/:id
curl https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6 \
  -H "Authorization: Bearer {token}"

DELETE/v1/webhooks/:id

Delete a webhook

Removes the webhook. Returns 204.

Request

DELETE
/v1/webhooks/:id
curl -X DELETE https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6 \
  -H "Authorization: Bearer {token}"

POST/v1/webhooks/:id/test

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

POST
/v1/webhooks/:id/test
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" }

POST/v1/webhooks/:id/enable

Re-enable a webhook

Turns deliveries back on after the endpoint was disabled, and resets failures to 0. Returns the webhook.

Request

POST
/v1/webhooks/:id/enable
curl -X POST https://mail.nullnet.sh/v1/webhooks/019a2f70-2b4c-7d6e-8f90-a1b2c3d4e5f6/enable \
  -H "Authorization: Bearer {token}"