Skip to content

Event types

Subscribe to any combination of these event types when you create a webhook.

Event Fires when Sets last_event to
email.scheduled An email with scheduled_at was accepted. scheduled
email.sent The email was handed to Amazon SES. sent
email.delivered The recipient’s mail server accepted the email. delivered
email.delivery_delayed Delivery is delayed or temporarily failing; SES keeps retrying. delivery_delayed
email.bounced Permanent failure (the address does not exist, domain rejects). bounced
email.complained The recipient marked the email as spam. complained
email.opened The recipient opened the email (needs open tracking). opened
email.clicked The recipient clicked a link (needs click tracking). clicked
email.failed SpacePost or SES could not send the email (rejected, rendering failure, paused team or tenant). failed
email.suppressed A recipient was skipped because it is on your suppression list. suppressed

Webhook bodies are exactly Resend’s, so handlers written for Resend keep working.

{
"type": "email.delivered",
"created_at": "2026-10-23T14:02:10.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"broadcast_id": "0192e1c4-0000-7000-8000-0000000000bb",
"tenant_id": "0192e1c4-5555-7000-8000-0000000000cc",
"tags": { "category": "welcome" }
}
}
  • type is the event; created_at is when the event happened.
  • data.email_id, from, to (all recipients of the email, not only the affected one), subject and created_at (when the email was created) are always present.
  • data.broadcast_id is present only for broadcast emails.
  • data.tenant_id is present only when the email was sent for a tenant (platform mode). It is our tenant id, not your external_id.
  • data.tags is present only if the email had tags, as an object ({ "name": "value" }).
  • Some events add one more object to data, named after the event: bounce, click, open, failed or suppressed. They are listed below. Nothing else is added: there are no top-level bounceType, delayType or reason keys.
{
"type": "email.scheduled",
"created_at": "2026-10-23T14:02:09.700Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Your trial ends tomorrow",
"created_at": "2026-10-23T14:02:09.512Z"
}
}
{
"type": "email.sent",
"created_at": "2026-10-23T14:02:10.100Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z"
}
}
{
"type": "email.delivered",
"created_at": "2026-10-23T14:02:11.350Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z"
}
}

Fired for SES delivery delays and for transient bounces. There are no extra fields for a plain delay. A transient bounce also carries the bounce object (see below, with type: "Transient").

{
"type": "email.delivery_delayed",
"created_at": "2026-10-23T14:07:00.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z"
}
}

Only permanent bounces reach this event, and they add the address to your suppression list. data.bounce has type (Permanent), subType and the receiving server’s message.

{
"type": "email.bounced",
"created_at": "2026-10-23T14:02:12.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["nobody@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "smtp; 550 5.1.1 The email account that you tried to reach does not exist."
}
}
}

Bounce sub types include General, NoEmail, Suppressed, OnAccountSuppressionList, MailboxFull, MessageTooLarge, ContentRejected and AttachmentRejected.

No extra fields. The address is added to your suppression list.

{
"type": "email.complained",
"created_at": "2026-10-23T15:30:00.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Our monthly newsletter",
"created_at": "2026-10-23T14:02:09.512Z"
}
}
{
"type": "email.opened",
"created_at": "2026-10-23T14:05:44.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"open": { "ipAddress": "203.0.113.7", "userAgent": "Mozilla/5.0 (Macintosh)" }
}
}
{
"type": "email.clicked",
"created_at": "2026-10-23T14:06:10.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"click": {
"link": "https://acme.com/get-started",
"ipAddress": "203.0.113.7",
"userAgent": "Mozilla/5.0 (Macintosh)",
"timestamp": "2026-10-23T14:06:10.000Z"
}
}
}
{
"type": "email.failed",
"created_at": "2026-10-23T14:02:10.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"failed": { "reason": "Attachment https://files.acme.com/report.pdf returned HTTP 404" }
}
}

suppressed.reason is suppression_list (the recipient is on your suppression list) or recipient_domain_does_not_accept_mail (the recipient’s domain has no mail server).

{
"type": "email.suppressed",
"created_at": "2026-10-23T14:02:10.000Z",
"data": {
"email_id": "0192e1c4-7a3b-7c1e-9d2f-3b5a8e6f4c10",
"from": "Acme <hello@acme.com>",
"to": ["user@example.com", "other@example.com"],
"subject": "Welcome to Acme",
"created_at": "2026-10-23T14:02:09.512Z",
"suppressed": { "recipients": ["other@example.com"], "reason": "suppression_list" }
}
}

The Node SDK exports a WebhookPayload union discriminated by type, and spacepost.webhooks.verify() returns it, so narrowing on event.type gives you the right data fields:

import type { WebhookPayload } from "@spacepost/node";
function handle(event: WebhookPayload) {
switch (event.type) {
case "email.bounced":
return event.data.bounce?.subType;
case "email.clicked":
return event.data.click?.link;
case "email.suppressed":
return event.data.suppressed.recipients;
case "email.failed":
return event.data.failed.reason;
default:
return event.data.tenant_id; // present when the email was sent for a tenant
}
}