# Webhooks

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

Source: https://docs.pidgely.com/guides/webhooks/

Webhooks push events to your server as they happen, so you don't have to poll. Webhooks created with [`POST /webhooks`](https://docs.pidgely.com/api-reference/webhooks/create/) are signed with the **[Standard Webhooks](https://www.standardwebhooks.com)** scheme (the same values are sent as `webhook-*` and `svix-*` headers), so any Standard Webhooks or Svix library can verify them.

```js tab="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 tab="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 tab="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 tab="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 tab="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
```

```bash tab="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](https://docs.pidgely.com/guides/suppressions/) |
| `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`):

```js
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:

```js tab="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 tab="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 tab="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 tab="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 tab="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](https://pidgely.com/blog/email-webhooks-signature-verification/).

## 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`](https://docs.pidgely.com/api-reference/webhooks/rotate-secret/)) 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`](https://docs.pidgely.com/api-reference/v1/webhooks/create/) 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.
