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.
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 stringJSON.stringify({ event, timestamp, data }), and the HMAC signature is computed over that exact string.
Signature headers
Every delivery carries these headers:
| Header | Value |
|---|---|
X-Signature | HMAC-SHA256 of the request body, hex-encoded, keyed with your webhook secret |
X-Timestamp | Unix seconds when the delivery was attempted |
X-Webhook-Id | Convex ID of the webhook subscription |
User-Agent | Owlat-Webhooks/1.0 |
Content-Type | application/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.
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>"
}
source | Meaning |
|---|---|
api | Created via the Contacts API |
import | Added through a CSV import |
form | Submitted a hosted form |
transactional | Auto-created while sending a transactional email |
inbound | Created 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\":\"...\"}]"
}
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
eventvalues 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
timestampis ISO-8601 on email/contact events butunsubscribedAtis epoch milliseconds ontopic.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:
| Status | Meaning |
|---|---|
pending | Logged, scheduled, not yet attempted |
retrying | An attempt failed and another is scheduled |
success | Endpoint returned a 2xx response |
failed | All 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.