Webhooks
Event payloads, signature verification and delivery retries.
Create an HTTPS endpoint with a full key and select event types. A sending key cannot manage webhooks. Save its whsec_… signing secret when it is created; later reads do not return it.
The OpenAPI EventType enum is: email.queued, email.sent, email.delayed, email.delivered, email.bounced, email.complained, email.opened, email.clicked, email.failed. Since 2026-10-07 the SES configuration set publishes no open or click events, so SES adds no tracking pixel and does not rewrite links. Sendtier creates no new email.opened or email.clicked events, and both types stay in the enum. Open and click events that SES published before that date can still be recorded and delivered, and they stay until data retention removes them. The sending API has no tracking switch. Subscribe only to the events your integration needs.
Event sources
email.queued is scheduled when Sendtier accepts an email through POST /emails or POST /emails/batch. A scheduled email produces this event at acceptance too.
email.sent is scheduled when the provider accepts the message. The worker produces email.failed when Sendtier cannot send, with reason invalid_recipient, all_recipients_suppressed, build_failed, provider_rejected or provider_unavailable. The provider can also produce email.failed for a reject or rendering failure.
email.delayed, email.delivered, email.bounced and email.complained come from provider notifications. email.opened and email.clicked follow the tracking note above.
Test-mode emails (an st_test_ key or test mode in the dashboard) produce email.queued and a worker event (email.sent, or email.failed for the reasons above) and no provider events. Those deliveries go to test endpoints. Each event has one delivery per subscribed endpoint of the same mode. Delivery attempts may repeat as described below.
Test and live endpoints
Create an endpoint with a test key or in dashboard test mode to make it a test endpoint. It receives only test-mode events: email.queued, then email.sent or email.failed. Live endpoints receive only live events, including provider events. Existing endpoints are live. An endpoint's mode cannot change.
You can register one URL as both a test endpoint and a live endpoint. Each has its own signing secret. Check test_mode before acting on an event.
Payload envelope
The dispatcher posts JSON with id (event ID), type, created_at (event occurrence time in UTC), test_mode (the endpoint's mode) and data. Event data is merged into data, then data.email_id is set to the stored email ID. It cannot be overridden by provider data. The request also sends Content-Type: application/json, User-Agent: Sendtier-Webhooks/1 and Sendtier-Event-Id (the event ID). Deduplicate processing by event ID, including manual resends.
{
"id": "evt_example",
"type": "email.delayed",
"created_at": "2026-10-06T12:00:00Z",
"test_mode": false,
"data": { "email_id": "em_example" }
}Verify Sendtier-Signature
Read the raw request body bytes before parsing JSON. The header is t=<Unix seconds>,v1=<hex HMAC-SHA256>. Sign the timestamp text, a period and the exact body bytes, using the entire secret string, including whsec_, as the HMAC key. Do not base64-decode the secret. During secret rotation it contains repeated v1 values: t=<Unix seconds>,v1=<new signature>,v1=<previous signature>. Accept the request when any signature matches your configured secret. Reject missing t/v1, duplicate t, malformed values and timestamps more than five minutes in either direction. Compare decoded signatures in constant time. Unknown header fields are ignored. These examples return a boolean; reject the request unless it is true.
JavaScript (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, header, body, now = Date.now() / 1000) {
const fields = {};
const signatures = [];
for (const part of header.split(",")) {
const field = part.trim();
const split = field.indexOf("=");
const key = field.slice(0, split);
if (split < 0 || !["t", "v1"].includes(key)) continue;
const value = field.slice(split + 1);
if (key === "v1") { signatures.push(value); continue; }
if (Object.hasOwn(fields, key)) return false;
fields[key] = value;
}
if (!/^[+-]?\d+$/.test(fields.t ?? "") || signatures.length === 0) return false;
if (signatures.some(value => !/^[a-f\d]{64}$/i.test(value))) return false;
const seconds = BigInt(fields.t);
if (seconds < -(2n ** 63n) || seconds > 2n ** 63n - 1n || Math.abs(now - Number(seconds)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${fields.t}.`).update(body).digest();
let matched = false;
for (const value of signatures) {
matched = timingSafeEqual(Buffer.from(value, "hex"), expected) || matched;
}
return matched;
}Python
import hashlib, hmac, re, time
def verify(secret, header, body, now=None):
fields = {}
signatures = []
for part in header.split(","):
key, separator, value = part.strip().partition("=")
if not separator or key not in ("t", "v1"):
continue
if key == "v1":
signatures.append(value)
continue
if key in fields:
return False
fields[key] = value
if not re.fullmatch(r"[+-]?[0-9]+", fields.get("t", "")) or not signatures:
return False
if any(not re.fullmatch(r"[0-9a-fA-F]{64}", value) for value in signatures):
return False
seconds = int(fields["t"])
if not -(2 ** 63) <= seconds <= 2 ** 63 - 1 or abs((time.time() if now is None else now) - seconds) > 300:
return False
expected = hmac.new(secret.encode(), fields["t"].encode() + b"." + body, hashlib.sha256).digest()
matched = False
for value in signatures:
matched = hmac.compare_digest(bytes.fromhex(value), expected) or matched
return matchedGo
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verify(secret, header string, body []byte, now time.Time) bool {
fields := map[string]string{}
var signatures []string
for _, part := range strings.Split(header, ",") {
key, value, found := strings.Cut(strings.TrimSpace(part), "=")
if !found || (key != "t" && key != "v1") { continue }
if key == "v1" { signatures = append(signatures, value); continue }
if _, duplicate := fields[key]; duplicate { return false }
fields[key] = value
}
timestamp, haveTime := fields["t"]
if !haveTime || len(signatures) == 0 { return false }
seconds, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil { return false }
skew := now.Sub(time.Unix(seconds, 0))
if skew > 5*time.Minute || skew < -5*time.Minute { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := mac.Sum(nil)
matched := false
for _, signature := range signatures {
provided, err := hex.DecodeString(signature)
if err != nil || len(provided) != sha256.Size { return false }
matched = hmac.Equal(provided, expected) || matched
}
return matched
}PHP
<?php
function verify(string $secret, string $header, string $body, ?float $now = null): bool {
$fields = [];
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2 || !in_array($pair[0], ['t', 'v1'], true)) { continue; }
if ($pair[0] === 'v1') { $signatures[] = $pair[1]; continue; }
if (array_key_exists($pair[0], $fields)) { return false; }
$fields[$pair[0]] = $pair[1];
}
if (!preg_match('/^[+-]?[0-9]+$/D', $fields['t'] ?? '') || $signatures === []) { return false; }
$digits = ltrim(ltrim($fields['t'], '+-'), '0');
$limit = str_starts_with($fields['t'], '-') ? '9223372036854775808' : '9223372036854775807';
if (strlen($digits) > strlen($limit) || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { return false; }
if (abs(($now ?? microtime(true)) - (int)$fields['t']) > 300) { return false; }
$expected = hash_hmac('sha256', $fields['t'] . '.' . $body, $secret, true);
$matched = false;
foreach ($signatures as $signature) {
if (!preg_match('/^[0-9a-fA-F]{64}$/D', $signature)) { return false; }
$matched = hash_equals($expected, hex2bin($signature)) || $matched;
}
return $matched;
}cURL: send a signed test callback
For local integration testing, sign the same bytes and send them with cURL. Configure SENDTIER_WEBHOOK_SECRET to your test endpoint's secret and WEBHOOK_URL to its HTTPS URL. Receive-side verification uses one of the server-language examples above.
body='{"id":"evt_example","type":"email.sent","created_at":"2026-10-06T12:00:00Z","test_mode":true,"data":{"email_id":"em_example"}}'
timestamp=$(date +%s)
signature=$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$SENDTIER_WEBHOOK_SECRET" | awk '{print $NF}')
curl "$WEBHOOK_URL" -H 'Content-Type: application/json' \
-H "Sendtier-Signature: t=$timestamp,v1=$signature" --data-binary "$body"Rotating the secret
Call POST /webhooks/{webhook_id}/rotate-secret with a full-scope API key, or as a dashboard owner, admin or developer. Save the new signing_secret from the 201 response immediately. Repeating the request with the same Idempotency-Key returns the webhook without the secret and does not rotate again. GET and list responses never expose either secret.
For the next 24 hours, each delivery has one timestamp and two v1 signatures, one for each secret. The verification examples above check every signature and accept a match against the configured secret. Deploy this multiple-signature verification before rotating, then update your configured secret during the overlap. At the exact expiry, deliveries have only the new signature. Another rotation during overlap returns 400 invalid_request, preserving the previous secret's full window.
If your deployment temporarily keeps both secrets, this code accepts a signature matching either:
// Uses the verify function above with the exact raw body bytes.
// Compare both secrets. A match on one must not skip the other compare.
const matchesNew = verify(newSecret, signatureHeader, rawBody);
const matchesPrevious = verify(previousSecret, signatureHeader, rawBody);
if (!matchesNew && !matchesPrevious) throw new Error("Invalid webhook signature");Remove the previous secret from your receiver after the overlap ends. Signature timestamps still have the five-minute verification window; deduplicate accepted events by event ID.
Retry schedule
Return a 2xx response after safely accepting the event. Network errors and other status codes retry after 30 seconds, 1 minute, 5 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, then 8 hours for subsequent attempts. Each interval starts after the failed attempt. A delivery fails when its next attempt would be later than 24 hours after creation. The delivery client timeout is 10 seconds.
List deliveries returns the newest page first. limit is 1 to 100 and defaults to 50. Pass next_cursor as after. Do not build that cursor yourself. Optional filters are webhook_id, status (pending, succeeded or failed) and event_type.
Inspect an attempt history returns the payload and attempts_history in attempt order. Resend a delivery accepts Idempotency-Key. A succeeded or failed delivery returns 202, becomes pending, and keeps the same event ID and payload with a new signature timestamp. A pending delivery returns 409 with code invalid_request, message Cannot resend a pending delivery. and hint Delivery is already pending.
curl --request POST https://api.sendtier.com/webhook-deliveries/whd_example/resend \
-H "Authorization: Bearer $SENDTIER_API_KEY" \
-H "Idempotency-Key: resend-example"Sources: EventType enum (openapi/openapi.yaml), SES event types (infra/terraform/main.tf), dispatcher BuildPayload and NextAttempt (internal/dispatcher/dispatcher.go), payload envelope (internal/webhook/payload.go), signing and verification (internal/webhook/sign.go), delivery client (internal/dispatcher/client.go), resend handler (internal/api/webhook_deliveries.go), acceptance events (internal/sending/emails.go), worker events (internal/worker/worker.go), provider events (internal/ingest/apply.go).