Webhooks
Receive message status events, verify signatures and handle retries idempotently.
1. Create a receiving endpoint
Manage /webhooks with a session token and create with url only. Prefer public HTTPS; private and loopback destinations are blocked and redirects are not followed. PATCH can change url or active/disabled; event selection is unsupported. The secret can be returned at creation and on reads. Store it safely; it is not an API Key.
curl -X POST "${SMTPDOG_API_ORIGIN}/webhooks" \
-H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"url":"https://example.com/hooks/email"}'
curl -X PATCH "${SMTPDOG_API_ORIGIN}/webhooks/${SMTPDOG_WEBHOOK_ID}" \
-H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"status":"disabled"}'
curl -X GET "${SMTPDOG_API_ORIGIN}/webhooks/${SMTPDOG_WEBHOOK_ID}/deliveries" \
-H "Authorization: Bearer ${SMTPDOG_SESSION_TOKEN}"2. Four status events
Current events are sent, bounced, deferred and failed. There are no opened, clicked or delivered events. The payload timestamp records enqueue time, unlike the signature time. event_id remains stable for retries of one delivery, but different endpoints or repeated business events may have different IDs. Strict ordering and guaranteed delivery are not promised.
| Field / setting | Type / status | Description |
|---|---|---|
| event_id / message_id | UUID string | Delivery event ID and message ID serve different purposes |
| event_type / status | string | sent / bounced / deferred / failed |
| from / to | string | One sender and recipient |
| timestamp | RFC3339 string | Enqueue time, unlike signature Unix seconds |
| smtp_response | string | Optional SMTP context |
{
"event_id": "00000000-0000-4000-8000-000000000001",
"event_type": "sent",
"message_id": "00000000-0000-4000-8000-000000000000",
"from": "sender@example.com",
"to": "recipient@example.com",
"status": "sent",
"timestamp": "2026-01-01T00:00:00Z"
}3. Verify the raw body
X-Webhook-Signature is t=<Unix seconds>,v1=<hex>. Compute HMAC-SHA256 over t + "." + raw request bytes using the full secret bytes. Never reserialize JSON or strip the secret prefix. Use a constant-time comparison. The examples require a receiver-configured time tolerance, not an assumed platform window.
package webhookexample
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// raw must be the original body bytes, before JSON parsing.
// tolerance is configured and reviewed by the receiver.
func Verify(secret string, raw []byte, header string,
now time.Time, tolerance time.Duration) bool {
if tolerance <= 0 { return false }
parts := strings.Split(header, ",")
if len(parts) != 2 { return false }
fields := map[string]string{}
for _, part := range parts {
pair := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(pair) != 2 { return false }
if _, exists := fields[pair[0]]; exists { return false }
fields[pair[0]] = pair[1]
}
ts, err := strconv.ParseInt(fields["t"], 10, 64)
if err != nil || ts <= 0 || strconv.FormatInt(ts, 10) != fields["t"] {
return false
}
delta := now.Sub(time.Unix(ts, 0))
if delta > tolerance || delta < -tolerance { return false }
sig, err := hex.DecodeString(fields["v1"])
if err != nil || len(sig) != sha256.Size { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fields["t"] + "."))
mac.Write(raw)
return hmac.Equal(mac.Sum(nil), sig)
}
// Read the raw request body and verify before parsing JSON.
// Then durably deduplicate event_id and apply business idempotency.
// Return 2xx only when safe; this function is not a full receiver.4. Deduplication, ACK and records
After verification, durably deduplicate and process idempotently before safely returning 2xx. Non-2xx and network failures trigger retries: currently an initial attempt plus at most five retries, subject to scheduling. GET /webhooks/{id}/deliveries returns the latest 50 records without full payload or event_id. Pausing does not clear backlog; deletion removes delivery history. No test, redelivery or secret-rotation endpoint exists.