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

ParameterTypeDescription
email.senteventEmail accepted by Txtly and handed to the mail provider.
email.deliveredeventEmail successfully delivered to the recipient mail server.
email.delivery_delayedeventDelivery temporarily delayed. Delivery will continue to be retried.
email.bouncedeventEmail bounced. The event details include the provider bounce payload (bounceType, diagnostic info). Permanent bounces add the recipient to the suppression list.
email.complainedeventRecipient marked the email as spam. The recipient is added to the suppression list.
email.openedeventRecipient opened the email (requires open tracking enabled).
email.clickedeventRecipient 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
  }
}
ParameterTypeDescription
typestringEvent type (e.g. email.delivered).
created_atstringISO 8601 timestamp of when the event occurred.
data.email_idstringID of the email the event relates to. Use GET /v1/emails/{id} to fetch it.
data.detailsobject | nullRaw 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:

ParameterTypeDescription
webhook-idheaderUnique delivery ID. Use for deduplication.
webhook-timestampheaderUnix timestamp (seconds) when the delivery was signed.
webhook-signatureheaderv1,<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:

ParameterTypeDescription
Attempt 1attemptImmediate (original delivery).
Attempt 2attempt~5 minutes after the first failure.
Attempt 3attempt~30 minutes after the previous failure.
Attempt 4attempt~2 hours after the previous failure.
Attempt 5attempt~5 hours after the previous failure.
Attempt 6attempt~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.