Webhooks

Receive signed HTTP callbacks for email lifecycle events.

Base URL: https://api.groundmail.dev

Manage endpoints in the dashboard (Webhooks) or via the API / SDK / CLI below.


Authentication

Authorization: Bearer gm_…

Dashboard session cookies also work for browser requests to /api/webhooks.


Supported events

Event When fired
email.received An inbound email arrived on a receive-enabled domain; retrieve full content from /received-emails/{id}
email.sent Groundmail accepted the message for delivery
email.delivered The message was delivered to the recipient mailbox provider
email.delivery_delayed Delivery was delayed or temporarily held
email.bounced Hard/soft bounce; recipient is auto-suppressed
email.complained Recipient marked the message as spam/complaint
email.opened Open pixel loaded when open tracking is on
email.clicked Tracked link clicked when click tracking is on
email.failed Delivery failed; permanent SMTP failures also auto-suppress the recipient
email.suppressed Send blocked because a recipient is on the suppression list
email.scheduled Email accepted with scheduled_at
contact.unsubscribed Recipient completed a managed unsubscribe. data.scope is all (global suppression) or category (product/newsletter opt-out only)
automation.started An active automation matched an incoming event and a run was created
automation.step_completed One automation step completed successfully
automation.completed A run finished all steps (or ended early on a false condition)
automation.failed A run failed (send failure, timeout, validation, etc.)

Open/click tracking are domain settings (open_tracking / click_tracking) configured per domain.


Create endpoint

POST /webhooks

{
  "url": "https://yourapp.com/webhooks/groundmail",
  "events": ["email.delivered", "email.bounced"]
}

events is optional — defaults to all supported events.

200 — Created

{
  "webhook": {
    "id": "clx…",
    "url": "https://yourapp.com/webhooks/groundmail",
    "events": ["email.delivered", "email.bounced"],
    "enabled": true,
    "created_at": "2026-07-25T12:00:00.000Z",
    "updated_at": "2026-07-25T12:00:00.000Z"
  },
  "secret": "whsec_…"
}

Copy secret immediately — it is only returned once. Use it to verify incoming webhook signatures.


List endpoints

GET /webhooks

Cursor pagination: limit, cursor → { webhooks, has_more, next_cursor }.


Retrieve endpoint

GET /webhooks/{id}

{
  "webhook": {
    "id": "clx…",
    "url": "https://yourapp.com/webhooks/groundmail",
    "events": ["email.delivered", "email.bounced"],
    "enabled": true,
    "created_at": "2026-07-25T12:00:00.000Z",
    "updated_at": "2026-07-25T12:00:00.000Z"
  }
}

Update endpoint

PATCH /webhooks/{id}

{
  "url": "https://yourapp.com/new-path",
  "events": ["email.bounced"],
  "enabled": false
}

All fields are optional.


Delete endpoint

DELETE /webhooks/{id}

{ "ok": true }

Test sink

POST /emails/test (full-scope API key)

{ "event": "email.bounced", "email_id": "msg_…", "deliver": true }

Builds a webhook payload (optionally for an existing email) and, when deliver is not false, enqueues delivery to your subscribed endpoints. Useful in CI.


Incoming webhook payload

Groundmail POSTs JSON to your endpoint URL when a subscribed event occurs.

Email event example

{
  "type": "email.received",
  "created_at": "2026-07-25T12:05:00.000Z",
  "data": {
    "email_id": "inb_abc123…",
    "to": ["sales@inbound.example.com"],
    "from": "Sender <sender@example.com>",
    "subject": "Hello",
    "status": "received",
    "rcpt_to": "sales@inbound.example.com",
    "cc": [],
    "message_id": "<abc@example.com>",
    "in_reply_to": null,
    "references": [],
    "spam_status": "NotSpam",
    "attachment_count": 1,
    "attachments": [
      {
        "filename": "invoice.pdf",
        "content_type": "application/pdf",
        "size": 48291
      }
    ],
    "received_at": "2026-07-25T12:04:59.000Z"
  }
}

For inbound events, attachment metadata is included but attachment bytes are not — fetch /received-emails/{id} for full bodies and base64 attachment data.

For bounces, type is email.bounced, status is bounced, and bounce_reason may contain a provider reason string.

Automation event example

{
  "type": "automation.step_completed",
  "created_at": "2026-07-26T12:05:00.000Z",
  "data": {
    "automation_id": "a1b2c3",
    "automation_name": "Welcome flow",
    "run_id": "r1s2t3",
    "status": "running",
    "event_name": "user.created",
    "step_index": 0,
    "step_type": "send_email",
    "error": null
  }
}

Contact unsubscribe examples

Global unsubscribe (scope: "all") adds the address to suppressions and blocks every send:

{
  "type": "contact.unsubscribed",
  "created_at": "2026-08-02T12:05:00.000Z",
  "data": {
    "email": "user@example.com",
    "source": "unsubscribe",
    "scope": "all",
    "category": null,
    "category_label": null,
    "suppression_id": "sup_…",
    "category_opt_out_id": null,
    "email_id": "msg_…"
  }
}

Category opt-out (scope: "category") stops product or newsletter mail only — transactional messages still send:

{
  "type": "contact.unsubscribed",
  "created_at": "2026-08-02T12:05:00.000Z",
  "data": {
    "email": "user@example.com",
    "source": "unsubscribe",
    "scope": "category",
    "category": "product",
    "category_label": "product emails",
    "suppression_id": null,
    "category_opt_out_id": "opt_…",
    "email_id": "msg_…"
  }
}

Check data.scope (and data.category when scope is category) before treating the address as fully suppressed.


Signature verification

Each delivery includes:

Groundmail-Signature: t=1721908800,v1=abc123…

SDK helper (preferred)

import { Groundmail, verifyWebhookSignature } from 'groundmail'

const ok = verifyWebhookSignature(secret, rawBody, request.headers.get('groundmail-signature'))
// or:
const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
client.webhooks.verify(secret, rawBody, header)

Verify by:

  1. Read the raw request body as a string (before JSON parsing).
  2. Parse t (Unix timestamp) and v1 (hex HMAC) from the header.
  3. Reject if t is more than 5 minutes from your server clock.
  4. Compute HMAC-SHA256(secret, "${t}.${rawBody}") and compare to v1 (constant-time).

Return 2xx quickly from your handler. Failed deliveries are retried by Bull at 0s, 5s, 30s, 2m, 2m (persisted in Redis via the groundmail:webhook-delivery queue). Run the worker:webhooks process alongside the app.


SDK

import { Groundmail } from 'groundmail'

const client = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

const { webhook, secret } = await client.webhooks.create({
  url: 'https://yourapp.com/webhooks/groundmail',
})

const { webhooks } = await client.webhooks.list()
const one = await client.webhooks.get(webhooks[0].id)
await client.webhooks.update(one.webhook.id, { enabled: false })
await client.webhooks.delete(webhooks[0].id)

CLI

groundmail webhooks list
groundmail webhooks create --url https://yourapp.com/hooks
groundmail webhooks delete --id clx…

Requires GROUNDMAIL_API_KEY or --api-key.


OpenAPI

Machine-readable spec: openapi/groundmail.yaml