Webhook Payloads

The authoritative wire contract for outbound webhooks: envelope, signature headers, per-event data shapes, and payload versioning.

This is the authoritative wire contract for the outbound webhooks Owlat sends to your endpoints. To configure subscriptions, verify signatures, and understand retry behavior at a higher level, start with the Webhooks reference; this page documents the exact JSON shape of every event.

Payload version 1 — frozen

Every payload below is frozen as of payloadVersion 1. Renaming a field, changing a type, dropping a field, or adding a required field is a breaking change for receivers and requires bumping the payload version. Adding a brand-new event type is not breaking, as long as your handler ignores unknown events.

Envelope

Every delivery is the JSON-stringified form of this envelope:

{
  "event": "<event-name>",
  "timestamp": "<ISO-8601 UTC>",
  "data": { /* event-specific, see below */ }
}
  • event — the event literal, e.g. email.delivered.
  • timestamp — when the envelope was built, as an ISO-8601 UTC string.
  • data — the event-specific payload. The HTTP request body is the exact string JSON.stringify({ event, timestamp, data }), and the HMAC signature is computed over that exact string.

Signature headers

Every delivery carries these headers:

HeaderValue
X-SignatureHMAC-SHA256 of the request body, hex-encoded, keyed with your webhook secret
X-TimestampUnix seconds when the delivery was attempted
X-Webhook-IdConvex ID of the webhook subscription
User-AgentOwlat-Webhooks/1.0
Content-Typeapplication/json

Webhook secrets use the whsec_... format and are generated when the webhook is created. Verify the signature by recomputing the HMAC over the raw body and comparing in constant time — see the verification example in the Webhooks reference.

Sign over the raw body, not a re-serialized object

X-Signature is computed over the literal request bytes. If your framework parses and re-serializes the JSON before you hash it, key ordering or whitespace can change and the signature will not match. Read the raw body string first, verify, then parse.

data shape constraints

In payload version 1, data is a flat map of primitives: string, number, boolean, or null. There are no nested objects or arrays. Anything structured is JSON-encoded into a string field — see topic.unsubscribed.listsRemoved below. A future payload version may relax this.

Email events

These fire as a campaign or transactional email moves through sending, delivery, and engagement.

email.sent

Fired when a campaign or transactional email is handed to the sending provider. Exactly one of campaignId / transactionalEmailId is set; the other is null.

{
  "email": "recipient@example.com",
  "campaignId": "<Id<'campaigns'> | null>",
  "transactionalEmailId": "<Id<'transactionalEmails'> | null>",
  "timestamp": "<ISO-8601>"
}

email.delivered

Fired when the receiving mail server accepts delivery.

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

email.opened

Fired on first open, tracked via a 1×1 pixel beacon.

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

email.clicked

Fired on each link click, via the tracked-URL redirect.

{
  "email": "recipient@example.com",
  "url": "https://www.example.com/landing",
  "timestamp": "<ISO-8601>"
}

email.bounced

Fired on a hard or soft bounce reported by the provider. message is the provider-supplied reason, or an empty string "" when none was given.

{
  "email": "recipient@example.com",
  "bounceType": "hard | soft",
  "message": "<provider reason or ''>",
  "timestamp": "<ISO-8601>"
}

email.complained

Fired when the recipient marks the email as spam (the provider's feedback loop).

{
  "email": "recipient@example.com",
  "timestamp": "<ISO-8601>"
}

Contact events

contact.created

Fired when a new contact is added. source records how the contact entered the system.

{
  "contactId": "<Id<'contacts'>>",
  "email": "person@example.com",
  "source": "api | import | form | transactional | inbound",
  "timestamp": "<ISO-8601>"
}
sourceMeaning
apiCreated via the Contacts API
importAdded through a CSV import
formSubmitted a hosted form
transactionalAuto-created while sending a transactional email
inboundCreated from an inbound message

Topic events

topic.unsubscribed

Fired when a contact unsubscribes from one or more topics.

{
  "contactId": "<Id<'contacts'>>",
  "email": "person@example.com",
  "unsubscribedAt": 1710505200000,
  "listsRemoved": "[{\"topicId\":\"...\",\"topicName\":\"...\"}]"
}
listsRemoved is a JSON-encoded string

Because payload version 1 only allows flat primitives, listsRemoved is a string containing JSON, not an array. You must JSON.parse it to read the entries. Each entry is { topicId, topicName }. Note also that unsubscribedAt is epoch milliseconds (a number), unlike the ISO-8601 timestamp on the email events.

const removed: Array<{ topicId: string; topicName: string }> =
  JSON.parse(data.listsRemoved)

test

Not subscribable — sent only when you use the test-fire button in the webhook dashboard to validate a receiver endpoint. Use it to confirm your endpoint reachability and signature verification before relying on live events.

{
  "message": "This is a test webhook from Owlat",
  "webhookId": "<Id<'webhooks'>>",
  "webhookName": "<your label>"
}

Payload version and forward-compatibility

The envelope carries no version field on the wire, but every delivery log row stores the payloadVersion it was built with. Write your receiver defensively:

  • Ignore unknown events. New event literals may be added without a version bump; handlers that strictly reject unknown event values will break on upgrade.
  • Ignore unknown fields. Additive fields within a shape are possible; do not fail on extras.
  • Branch on type, not position. Remember timestamp is ISO-8601 on email/contact events but unsubscribedAt is epoch milliseconds on topic.unsubscribed.

A future version 2 may relax the flat-primitive constraint and allow nested arrays/objects (retiring the JSON-string encoding on listsRemoved). When that happens, the version will be bumped and both shapes documented.

Retry behavior and delivery logs

Owlat treats any non-2xx response — or a network error, timeout, or a URL that resolves to a private/local network — as a failed delivery and retries up to 3 attempts total with backoff:

Attempt 1

Delivered immediately when the event fires.

Attempt 2

Retried after 1 minute if attempt 1 failed.

Attempt 3

Retried after 5 minutes if attempt 2 failed. After this final attempt, the delivery is marked failed and not retried again.

Each request has a 30-second timeout and follows no redirects. Every attempt is recorded in a delivery log with one of these statuses:

StatusMeaning
pendingLogged, scheduled, not yet attempted
retryingAn attempt failed and another is scheduled
successEndpoint returned a 2xx response
failedAll attempts exhausted, or the webhook was disabled/not found

Logs capture the HTTP status code, a truncated response body (first 1000 characters), an error message on failure, and the per-attempt duration. Because Owlat retries on any non-2xx, your endpoint should acknowledge with a 2xx as soon as it has durably received the event and process asynchronously — and it should be idempotent, since a slow or flaky 2xx can still result in a duplicate delivery.