Skip to content
Pidgely Docs

Guides

Webhooks

Receive signed events for deliveries, bounces, complaints, domains and contacts. Event list, payloads, svix-* / Standard Webhooks signature verification and retries.

View as Markdown

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.

Node.js
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);
Python
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
<?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);
Go
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)
}
Ruby
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 result
cURL
curl -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:

JSON
{
  "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):

JavaScript
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:

Node.js
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);
});
Python
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
<?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'));
Go
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")
}
Ruby
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/1 and Content-Type: application/json.
  • Any 2xx response 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 Gone stops 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.