---
title: "Simulate a webhook event (test sink for CI)"
method: POST
path: "/emails/test"
tags: ["Emails"]
---

# Simulate a webhook event (test sink for CI)

`POST /emails/test`

## Request

- Base URL: `https://api.groundmail.dev`
- URL: `https://api.groundmail.dev/emails/test`
- Auth: HTTP bearer

## Request body

- object
  - `event` 'email.received' | 'email.sent' | 'email.delivered' | 'email.delivery_delayed' | 'email.held' | 'email.bounced' | 'email.complained' | 'email.opened' | 'email.clicked' | 'email.failed' | 'email.suppressed' | 'email.scheduled', required
  - `email_id` string: Optional existing email public id (`msg_…`)
  - `webhook_id` string: Optional webhook endpoint id (`wh_…`) for a single-endpoint test delivery
  - `sync` boolean: When true, deliver synchronously and return per-endpoint HTTP results (defaults to true when `webhook_id` is set)
  - `deliver` boolean: When true, enqueue deliveries to account webhooks
  - `tenant` string: Put this tenant (`external_id` or `id`) on the payload, so endpoints filtered to that tenant receive it. Testing a tenant-filtered endpoint with `webhook_id` needs one of its tenants.

## Response `200`

Payload built (and optionally delivered)

- object
  - `ok` boolean, required
  - `payload` EmailWebhookEventPayload, required
    - `type` 'email.received' | 'email.sent' | 'email.delivered' | 'email.delivery_delayed' | 'email.held' | 'email.bounced' | 'email.complained' | 'email.opened' | 'email.clicked' | 'email.failed' | 'email.suppressed' | 'email.scheduled', required
    - `created_at` string, date-time, required
    - `data` object, required
      - `email_id` string, required
      - `to` string[], required
      - `from` string, required
      - `subject` string, required
      - `status` string, required
      - `bounce_reason` string, nullable
      - `bounce_type` 'hard' | 'block', nullable: Why the email bounced. Null when it has not bounced (and for complaints). - `hard`: the address cannot receive mail (unknown user, disabled mailbox, domain with no mail server). The address is added to your suppression list. - `block`: the receiving server refused the message because of the sending IP's or domain's reputation or policy, for example a blocklist listing or a mailbox provider's spam policy. The address is fine and is not suppressed, and block bounces do not count toward your bounce rate. You can send to it again later. Present on `email.bounced` and `email.failed` webhook events and on the email object. Treat unknown future values like `hard`.
      - `tags` EmailTag[]: On every outbound `email.*` event: the email's tags as sent (`[]` when it had none), so you can match an event to your own records without storing the email id first. Not on `email.received`.
        - `name` string, required
        - `value` string, required
      - `held_back` HeldBackRecipient[]: On `email.suppressed` when Groundmail validation held back every recipient.
        - `email` string, required: The held-back address (trimmed, lowercased)
        - `reason` 'invalid_address' | 'no_mail_server' | 'previously_bounced' | 'mailbox_not_found', required: `invalid_address`: the address cannot exist. `no_mail_server`: the domain does not exist or takes no email (no MX or A record, or a null MX). `previously_bounced`: the address hard-bounced (mailbox does not exist) for a Groundmail sender in the last 90 days. `mailbox_not_found`: Groundmail validation already knows the mailbox does not exist. The last two can be turned off per account in Settings → Suppressions.
      - `test` true: Present (and `true`) on simulated events: sends with a test key or to simulator addresses, and `POST /emails/test`. Absent on events from real mail.
      - `tenant` string: The `external_id` of the tenant the email was sent for. Present only on tenant email.
      - `stream` 'transactional' | 'product' | 'newsletter': The mail stream an email went out on, from its `category`. Each stream sends from its own IPs. `newsletter` is for approved accounts only.
      - `rcpt_to` string, email
      - `cc` string[]
      - `message_id` string, nullable
      - `in_reply_to` string, nullable
      - `references` string[]
      - `held_reason` string: On `email.held`, why the email was held (content scanning, or a pause on the account).
      - `spam_status` 'clean' | 'suspicious' | 'spam', nullable: On `email.received`, the content scanning verdict.
      - `spam_score` number, nullable: Content scanning score: on `email.received` (null when not scanned), and on `email.held` when content scanning held the email.
      - `attachment_count` integer
      - `attachments` ReceivedEmailAttachmentMeta[]
        - `filename` string, required
        - `content_type` string, required
        - `size` integer, required
        - `content_id` string, nullable
      - `received_at` string, date-time
  - `delivered` boolean, required
  - `deliveries` object[], required
    - `endpoint_id` string, required
    - `url` string, required
    - `success` boolean, required
    - `status` integer
    - `error` string

## Other responses

- `400`: Invalid event
- `401`: Missing or invalid API key

## Changes

- **2026-10-19** `479821c9fecf`: 1 info
  - added the optional property `payload/data/tags` to the response with the `200` status

[Operation history](https://skmtc.dev/groundmail/apis/groundmail-api/changes/emails/test/post.md)

---

[API](https://skmtc.dev/groundmail/apis/groundmail-api.md) · [All operations](https://skmtc.dev/groundmail/apis/groundmail-api/llms.txt) · [OpenAPI document](https://skmtc.dev/groundmail/apis/groundmail-api/revisions/479821c9fecf?raw)
