Guides
Webhooks
Receive signed events for deliveries, bounces, complaints, domains and contacts. Event list, payloads, svix-* / Standard Webhooks signature verification and retries.
Webhooks push events to your server as they happen, so you don't have to poll. Webhooks created with POST /webhooks are signed with the Standard Webhooks scheme (the same values are sent as webhook-* and svix-* headers), so any Standard Webhooks or Svix library can verify them.
import { Pidgely } from 'pidgely';
const pidgely = new Pidgely(process.env.PIDGELY_API_KEY);
const { data, error } = await pidgely.webhooks.create({
endpoint: 'https://acme.com/webhooks/pidgely',
events: ['email.delivered', 'email.bounced', 'email.complained'],
});
console.log(data ?? error);import os
import pidgely
pidgely.api_key = os.environ["PIDGELY_API_KEY"]
result = pidgely.Webhooks.create({
"endpoint": "https://acme.com/webhooks/pidgely",
"events": ["email.delivered", "email.bounced", "email.complained"],
})
print(result)<?php
require __DIR__ . '/vendor/autoload.php';
$pidgely = Pidgely::client(getenv('PIDGELY_API_KEY'));
$result = $pidgely->webhooks->create([
'endpoint' => 'https://acme.com/webhooks/pidgely',
'events' => ['email.delivered', 'email.bounced', 'email.complained'],
]);
var_dump($result);package main
import (
"context"
"fmt"
"os"
"github.com/pidgely/pidgely-go"
)
func main() {
ctx := context.Background()
client := pidgely.NewClient(os.Getenv("PIDGELY_API_KEY"))
result, err := client.Webhooks.Create(ctx, &pidgely.CreateWebhookRequest{
Endpoint: "https://acme.com/webhooks/pidgely",
Events: []string{"email.delivered", "email.bounced", "email.complained"},
})
if err != nil {
panic(err)
}
fmt.Println(result)
}require "pidgely"
Pidgely.api_key = ENV["PIDGELY_API_KEY"]
result = Pidgely::Webhooks.create({
endpoint: "https://acme.com/webhooks/pidgely",
events: ["email.delivered", "email.bounced", "email.complained"]
})
puts resultcurl -X POST 'https://api.pidgely.com/webhooks' \
-H "Authorization: Bearer $PIDGELY_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"endpoint": "https://acme.com/webhooks/pidgely",
"events": [
"email.delivered",
"email.bounced",
"email.complained"
]
}'The response contains the signing_secret (whsec_…). Store it with your other secrets: you need it to verify every request.
Events
| Event | Sent when |
|---|---|
email.sent |
The email was accepted and queued (one event per email, with the non-suppressed recipients) |
email.delivered |
A recipient's server accepted the message |
email.delivery_delayed |
A temporary failure; Pidgely will retry (for up to 72 hours) |
email.bounced |
A permanent failure; the address is now suppressed |
email.complained |
The recipient marked the message as spam (feedback loop) |
email.failed |
Pidgely gave up: retry window exhausted, account suspended or content expired |
email.suppressed |
The recipient was skipped because the address is on the suppression list |
domain.updated |
A domain's verification status changed |
contact.created, contact.updated, contact.deleted |
A contact changed |
events must list at least one event. email.opened, email.clicked, email.received, email.scheduled, domain.created and domain.deleted are accepted for compatibility but never sent (open/click tracking and inbound email are not available yet).
Payloads
Every request is a POST with a JSON body { type, created_at, data }. Email events identify the email (email_id), the recipient and, where relevant, the delivery:
{
"type": "email.delivered",
"created_at": "2026-10-12T14:03:11.123456Z",
"data": {
"created_at": "2026-10-12T14:03:11.123456Z",
"email_id": "0199d8a2-6f3c-7c51-9e2a-3b1f0c6d4e21",
"delivery_id": "0199d8a2-7b55-7e10-9c3d-1f2a3b4c5d6e",
"recipient": "steve@example.com",
"to": ["steve@example.com"],
"transport": "mta",
"remote_host": "gmail-smtp-in.l.google.com",
"response": "250 2.0.0 OK"
}
}Fields in data per event:
| Event | data fields (besides created_at, email_id, delivery_id, to) |
|---|---|
email.sent |
from, subject, to, tags, scheduled_at |
email.delivered |
recipient, transport, remote_host, response |
email.delivery_delayed |
recipient, attempt, class (transient, blocked, unreachable), code, enhanced_code, message, next_attempt_at |
email.bounced |
recipient, type (hard), source (smtp, dsn, relay), code, enhanced_code, message |
email.complained |
recipient, source (fbl, relay) |
email.failed |
recipient, reason (retry_window_exhausted, account_suspended, content_expired) |
email.suppressed |
recipient, reason (hard_bounce, complaint, unsubscribe, manual) |
domain.updated |
domain_id, domain, status, previous (pending, verified, failed) |
contact.* |
id, email, first_name, last_name, unsubscribed, created_at, updated_at |
Verify signatures
Each request carries three headers, under both the Svix and the Standard Webhooks names:
| Svix | Standard Webhooks | Content |
|---|---|---|
svix-id |
webhook-id |
Message id (evt_…), the same on every retry |
svix-timestamp |
webhook-timestamp |
Unix seconds of this attempt |
svix-signature |
webhook-signature |
v1,<base64 HMAC-SHA256> (space-separated list) |
The signature is an HMAC-SHA256, keyed with the base64-decoded part of the secret after whsec_, over "{id}.{timestamp}.{raw body}". Verify against the raw request body (before JSON parsing), compare in constant time, and reject timestamps older than a few minutes to stop replays.
The Node.js SDK does it for you (it is async, so remember the await):
import { verifyWebhook } from 'pidgely';
const event = await verifyWebhook({ payload: rawBody, headers, webhookSecret: process.env.PIDGELY_WEBHOOK_SECRET });The svix and standardwebhooks libraries work too. Or verify it yourself in any language:
import crypto from 'node:crypto';
import express from 'express';
function verify(rawBody, headers, secret) {
const id = headers['svix-id'] ?? headers['webhook-id'];
const ts = headers['svix-timestamp'] ?? headers['webhook-timestamp'];
const sigs = headers['svix-signature'] ?? headers['webhook-signature'];
if (!id || !ts || !sigs) throw new Error('missing signature headers');
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error('timestamp too old');
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
const ok = sigs.split(' ').some((s) => {
const sig = s.split(',')[1] ?? '';
return sig.length === expected.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
if (!ok) throw new Error('invalid signature');
return JSON.parse(rawBody);
}
const app = express();
app.post('/webhooks/pidgely', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verify(req.body.toString('utf8'), req.headers, process.env.PIDGELY_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
// deduplicate on req.headers['svix-id'], then handle event.type
res.sendStatus(200);
});import base64, hashlib, hmac, json, time
def verify(body: bytes, headers, secret: str):
msg_id = headers.get("svix-id") or headers.get("webhook-id")
ts = headers.get("svix-timestamp") or headers.get("webhook-timestamp")
sigs = headers.get("svix-signature") or headers.get("webhook-signature")
if not (msg_id and ts and sigs):
raise ValueError("missing signature headers")
if abs(time.time() - int(ts)) > 300:
raise ValueError("timestamp too old")
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
expected = base64.b64encode(mac).decode()
if not any(hmac.compare_digest(s.split(",", 1)[-1], expected) for s in sigs.split(" ")):
raise ValueError("invalid signature")
return json.loads(body)
# Flask: event = verify(request.get_data(), request.headers, os.environ["PIDGELY_WEBHOOK_SECRET"])<?php
function verify_webhook(string $body, array $h, string $secret): array {
$id = $h['svix-id'] ?? $h['webhook-id'] ?? null;
$ts = $h['svix-timestamp'] ?? $h['webhook-timestamp'] ?? null;
$sigs = $h['svix-signature'] ?? $h['webhook-signature'] ?? null;
if (!$id || !$ts || !$sigs) throw new Exception('missing signature headers');
if (abs(time() - (int) $ts) > 300) throw new Exception('timestamp too old');
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
foreach (explode(' ', $sigs) as $s) {
$parts = explode(',', $s, 2);
if (isset($parts[1]) && hash_equals($expected, $parts[1])) return json_decode($body, true);
}
throw new Exception('invalid signature');
}
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
$event = verify_webhook(file_get_contents('php://input'), $headers, getenv('PIDGELY_WEBHOOK_SECRET'));package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func header(h http.Header, a, b string) string {
if v := h.Get(a); v != "" {
return v
}
return h.Get(b)
}
// Verify checks a Pidgely webhook. body must be the raw request body.
func Verify(body []byte, h http.Header, secret string) error {
id, ts, sigs := header(h, "svix-id", "webhook-id"), header(h, "svix-timestamp", "webhook-timestamp"), header(h, "svix-signature", "webhook-signature")
if id == "" || ts == "" || sigs == "" {
return errors.New("missing signature headers")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return errors.New("timestamp too old")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, s := range strings.Split(sigs, " ") {
if _, sig, ok := strings.Cut(s, ","); ok && hmac.Equal([]byte(sig), []byte(expected)) {
return nil
}
}
return errors.New("invalid signature")
}require "base64"
require "json"
require "openssl"
def verify_webhook(body, headers, secret)
id = headers["svix-id"] || headers["webhook-id"]
ts = headers["svix-timestamp"] || headers["webhook-timestamp"]
sigs = headers["svix-signature"] || headers["webhook-signature"]
raise "missing signature headers" unless id && ts && sigs
raise "timestamp too old" if (Time.now.to_i - ts.to_i).abs > 300
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{ts}.#{body}"))
ok = sigs.split(" ").any? { |s| OpenSSL.secure_compare(s.split(",", 2).last.to_s, expected) }
raise "invalid signature" unless ok
JSON.parse(body)
end
# Rails: verify_webhook(request.raw_post, request.headers, ENV.fetch("PIDGELY_WEBHOOK_SECRET"))More background in Email webhooks: verifying signatures and handling retries.
Delivery and retries
- Requests come from Pidgely with
User-Agent: pidgely-webhooks/1andContent-Type: application/json. - Any
2xxresponse is a success. Answer quickly (the timeout is 10 seconds) and do heavy work asynchronously. - Anything else (error status, timeout, connection failure) is retried up to 15 attempts with exponential backoff and jitter: roughly 30–60 s, then 1–2 min, 2–4 min, … capped at one hour between attempts. Retries span about 4.5 to 9 hours in total.
410 Gonestops retries for that event immediately.- Delivery is at least once and order is not guaranteed: deduplicate on
svix-id/webhook-id, which is the same on every attempt (so is the body). - Endpoints are never disabled automatically. A disabled or deleted endpoint drops its queued deliveries.
- Rotating the secret (
POST /webhooks/{id}/signing-secret/rotate) takes effect immediately, including for queued retries.
Endpoint requirements
The endpoint must be a public https URL without credentials. Private, loopback, link-local and other internal addresses are refused, both when you register the endpoint and on every delivery (which also defeats DNS rebinding). Redirects are not followed.
Native events
Webhooks created with the native POST /v1/webhooks use native names and include a top-level id: email.queued (= email.sent), email.delivered, email.deferred (= email.delivery_delayed), email.bounced, email.failed, email.complained, email.suppressed, email.unsubscribed, email.canceled, domain.status_changed (= domain.updated), account.status_changed and contact.*. An empty events list subscribes to all of them. The signature scheme is the same.