Skip to main content

Documentation Index

Fetch the complete documentation index at: https://help.xoxo.email/llms.txt

Use this file to discover all available pages before exploring further.

Webhooks let your app react to changes in a connected XOXO account without polling the REST API. When something happens — a new subscriber signs up, a tag is added, an email finishes sending — XOXO sends an HTTP POST to a URL you control. Webhooks are scoped to OAuth apps. Personal API keys don’t deliver webhooks — they’re for one-off scripts and integrations against your own account.

Registering a webhook URL

OAuth apps are configured by the XOXO team. When you request your client credentials, include:
  • Webhook URL — an HTTPS endpoint that accepts POST requests
  • Contact emails — addresses to notify if your endpoint starts failing
You can update either at any time by emailing hi@xoxo.email.

When events fire

Your app receives an event for an account when all of the following are true:
  • The account has authorized your app and the access token is still valid
  • The event’s resource type is one of: account, subscriber, tag, field, group, email
  • Your app has a webhook URL set and the webhook isn’t disabled (see Failures)

Event types

Events follow a <resource>.<action> naming convention.
EventWhen it fires
account.deletedAccount is deleted
account.updatedAccount name, slug, sender address, physical address, image, or logo changes
email.createdA draft email is created
email.deletedA draft is removed
email.scheduledAn email is scheduled to send
email.sentAn email finishes sending to all recipients
email.unscheduledA scheduled send is cancelled
email.updatedAn email is edited
field.createdA custom field is added
field.deletedA custom field is removed
field.updatedA custom field is renamed
group.createdA group is created
group.deletedA group is removed
group.updatedA group’s name, description, color, or filters change
subscriber.createdA subscriber is added
subscriber.deletedA subscriber is removed
subscriber.updatedA subscriber’s fields or tags change
tag.createdA tag is created
tag.deletedA tag is removed
tag.updatedA tag’s name or color changes

Payload format

Every webhook is a POST with a JSON body in this envelope:
{
  "id": "01934b2e-7a3c-7ab1-9f2e-d3c4b5a6e7f8",
  "event": "subscriber.created",
  "occurred_at": "2026-05-04T18:32:11Z",
  "account_id": "acc_4f1b8e2c",
  "data": {
    "id": "sub_9a2c1d4e",
    "email": "alex@example.com",
    "source": "api",
    "email_count": 0,
    "like_count": 0,
    "emailed_at": null,
    "liked_at": null,
    "fields": { "first_name": "Alex" },
    "tags": ["customer"],
    "created_at": "2026-05-04T18:32:11Z",
    "updated_at": "2026-05-04T18:32:11Z"
  }
}
FieldDescription
idUnique, time-ordered UUID v7 for this delivery. Use it to deduplicate retries.
eventThe event name from the table above.
occurred_atISO 8601 timestamp of when the event happened.
account_idThe XOXO account the event belongs to.
dataThe full resource as it appears in the REST API. For *.deleted events, data contains the resource as it was just before deletion.
The shape of data matches the corresponding REST API resource exactly — see the endpoint reference for each resource’s fields.

Headers

Each delivery includes:
HeaderValue
Content-Typeapplication/json
User-Agentxoxo-webhooks/1.0
XOXO-EventThe event name, e.g. subscriber.created
XOXO-Event-IdThe event id (also in the body)
XOXO-SignatureHMAC signature — see below

Verifying signatures

Always verify the XOXO-Signature header before trusting a payload. The header has the form:
XOXO-Signature: t=1714849931,v1=5d41402abc4b2a76b9719d911017c592...
To verify:
  1. Split the header into the timestamp (t) and the signature (v1)
  2. Build the signed string: <timestamp>.<raw_request_body>
  3. Compute HMAC-SHA256 using your app’s client_secret as the key
  4. Compare the hex digest to v1 in constant time
import crypto from "node:crypto";

function verify(payload, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const signed = `${parts.t}.${payload}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify the signature against the raw, unparsed request body. Re-serializing parsed JSON will produce a different byte sequence and the signature will not match.
To prevent replay attacks, reject events whose t timestamp is older than a few minutes.

Responding

Reply with any 2xx status within 5 seconds to acknowledge the event. The body is ignored. If you need to do slow work in response to a webhook, queue it on your end and return 200 immediately.

Failures

Failed deliveries are retried with exponential backoff up to 12 times over roughly 10 hours. A delivery counts as failed if it:
  • returns a non-2xx status code (other than 410 — see below)
  • times out after 5 seconds
  • can’t be reached at all
After 20 consecutive failures, the webhook is automatically disabled and XOXO emails the contact addresses on file. No further events are delivered until the webhook is re-enabled — email hi@xoxo.email once you’ve fixed the endpoint.
Returning 410 Gone immediately disables the webhook with no further retries. Use it when the URL is permanently retired.

Idempotency

Retries replay the same id. Treat that ID as the idempotency key on your end so a redelivered event isn’t processed twice.