Webhook Events
Txtly sends webhook events to notify your application about email delivery lifecycle changes. This guide covers event types, payload structure, signature verification, and best practices.
Event types
Subscribe to any combination of the following event types when creating a webhook:
Email events
| Parameter | Type | Description |
|---|---|---|
email.sent | event | Email accepted by Txtly and handed to the mail provider. |
email.delivered | event | Email successfully delivered to the recipient mail server. |
email.delivery_delayed | event | Delivery temporarily delayed. Delivery will continue to be retried. |
email.bounced | event | Email bounced. The event details include the provider bounce payload (bounceType, diagnostic info). Permanent bounces add the recipient to the suppression list. |
email.complained | event | Recipient marked the email as spam. The recipient is added to the suppression list. |
email.opened | event | Recipient opened the email (requires open tracking enabled). |
email.clicked | event | Recipient clicked a link in the email (requires click tracking enabled). |
Payload structure
Every webhook delivery sends a JSON payload with a consistent envelope:
{
"type": "email.delivered",
"created_at": "2026-03-21T14:30:00.0000000Z",
"data": {
"email_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"details": null
}
}| Parameter | Type | Description |
|---|---|---|
type | string | Event type (e.g. email.delivered). |
created_at | string | ISO 8601 timestamp of when the event occurred. |
data.email_id | string | ID of the email the event relates to. Use GET /v1/emails/{id} to fetch it. |
data.details | object | null | Raw provider event payload (e.g. the SES bounce notification). Null when no provider payload is available. |
The delivery ID — unique per delivery and useful for deduplication — is sent in the webhook-id request header rather than the payload.
Bounce event example
{
"type": "email.bounced",
"created_at": "2026-03-21T14:31:00.0000000Z",
"data": {
"email_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"details": {
"notificationType": "Bounce",
"mail": { "messageId": "ses-message-id" },
"bounce": {
"bounceType": "Permanent",
"bouncedRecipients": [
{ "emailAddress": "invalid@example.com", "diagnosticCode": "smtp; 550 5.1.1 user unknown" }
]
}
}
}
}Signature verification
Every webhook delivery includes three headers:
| Parameter | Type | Description |
|---|---|---|
webhook-id | header | Unique delivery ID. Use for deduplication. |
webhook-timestamp | header | Unix timestamp (seconds) when the delivery was signed. |
webhook-signature | header | v1,<signature> — a Base64-encoded HMAC-SHA256 signature. |
The signature is an HMAC-SHA256 of the string {webhook-timestamp}.{rawBody} computed with the webhook's signing secret and encoded as Base64. Verify it to ensure the payload was sent by Txtly and has not been tampered with.
Verification steps
1. Read the webhook-timestamp header and the signature after the v1, prefix of the webhook-signature header. 2. Concatenate the timestamp, a dot, and the raw request body. 3. Compute an HMAC-SHA256 using your signing secret and Base64-encode it. 4. Compare the computed signature with the received one using a timing-safe comparison. 5. Optionally reject payloads with timestamps older than 5 minutes to prevent replay attacks.
Node.js example
import crypto from "crypto";
function verifyWebhook(
payload: string, // raw request body
signatureHeader: string, // webhook-signature header
timestamp: string, // webhook-timestamp header
secret: string
): boolean {
const signature = signatureHeader.replace("v1,", "");
const signedPayload = timestamp + "." + payload;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}Python example
import base64
import hmac
import hashlib
def verify_webhook(
payload: bytes, # raw request body
signature_header: str, # webhook-signature header
timestamp: str, # webhook-timestamp header
secret: str,
) -> bool:
signature = signature_header.removeprefix("v1,")
signed_payload = f"{timestamp}.".encode() + payload
expected = base64.b64encode(
hmac.new(secret.encode(), signed_payload, hashlib.sha256).digest()
).decode()
return hmac.compare_digest(signature, expected)Retry schedule
If your endpoint returns a non-2xx status code or times out (15 second limit), Txtly retries delivery with exponential backoff. At most 6 attempts are made in total:
| Parameter | Type | Description |
|---|---|---|
Attempt 1 | attempt | Immediate (original delivery). |
Attempt 2 | attempt | ~5 minutes after the first failure. |
Attempt 3 | attempt | ~30 minutes after the previous failure. |
Attempt 4 | attempt | ~2 hours after the previous failure. |
Attempt 5 | attempt | ~5 hours after the previous failure. |
Attempt 6 | attempt | ~10 hours after the previous failure (final attempt). |
After the final attempt fails, the delivery is marked as failed. You can view delivery history and manually replay deliveries from the dashboard or via POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay.
Best practices
Return a 2xx response within a few seconds to acknowledge receipt. Process the event asynchronously in a background job to avoid timeouts. Use the webhook-id header for idempotent processing — Txtly may deliver the same event more than once during retries. Always verify the signature before processing the payload. Store the raw payload for debugging and audit purposes.