Sign in Create account
Guides

Webhooks: events, payloads and signature verification

Receive delivery events, bounces, clicks, received emails and domain changes on your server, in real time and signed.

#Set up

Register the URL that will receive the events in the dashboard, under Webhooks, or with POST /webhooks, choosing the events. The URL must be HTTPS and public. Keep the signing_secret (whsec_…): it is how you check that a request came from Postal. How many endpoints you can have depends on the plan (see Limits).

#Events

EventWhen it happens
email.sentPostal handed the email to the sending provider.
email.deliveredThe recipient's server accepted the message.
email.delivery_delayedDelivery was delayed (full mailbox, server unavailable…). Retries happen automatically.
email.bouncedThe email bounced. On permanent bounces, the address goes on the suppression list.
email.complainedThe recipient marked it as spam. The address goes on the suppression list.
email.openedThe email was opened (open tracking is on).
email.clickedA link was clicked (click tracking is on).
email.repliedSomeone replied to an email sent with reply detection (detect_replies on a broadcast or on a send step of an automation). Automatic replies, such as out-of-office messages, arrive with reply.auto: true.
email.failedThe email could not be sent. failed.reason gives the reason.
email.scheduledThe email was scheduled.
email.suppressedRecipients were on the suppression list and were removed from the send.
email.receivedAn inbox received a message. The code from an OTP-Token header is not included in the payload.
domain.createdA domain was added.
domain.updatedA domain changed (verification status, tracking, capabilities).
domain.deletedA domain was removed (including when another account claimed it).
suppression.addedAn address was added to the suppression list.
suppression.removedAn address was removed from the suppression list.
contact.createdA contact was created (through the API, an import or a form).
contact.updatedA contact changed (data, properties or unsubscribe).
contact.deletedA contact was deleted.
contact.segment_enteredA contact became part of a dynamic segment because it now matches the filters. Static segments do not generate the event.
contact.segment_exitedA contact left a dynamic segment because it no longer matches the filters.
form.submittedSomeone signed up through a form and joined the audience (after confirmation, with double opt-in).
broadcast.sentA broadcast finished sending: all recipients were processed.
sending.pausedThe team's sending was paused because of a high bounce or complaint rate (see Limits). From then on, sends receive 403 sending_paused. The data carries the reason in reason (in the team's language), reason_code and reason_details: see Sending pauses.
sending.resumedThe team's sending was resumed.
inbox.thread.createdA new thread was opened in an inbox, by a received email or by a message sent from the inbox.
inbox.thread.updatedThe folder, read state, assignee or tags of a thread changed (PATCH, bulk action, trash or AI agent), or a new email moved it out of the archive. One event per change.
inbox.thread.deletedA thread was permanently deleted (from the trash).
inbox.draft.createdAn inbox's AI agent created a draft reply. Drafts created by people do not generate an event.

#Payload format

Each delivery is a POST with a JSON body of three fields: type, created_at and data. In email events, data carries the email and, depending on the type, an extra object. The tags arrive as an object, {"category": "shipping"} (tag name → value). If the email came from a broadcast, data also carries broadcast_id; if it is tied to a contact (broadcasts and automations), contact_id and contact_external_id.

email.delivered
{
  "type": "email.delivered",
  "created_at": "2026-10-24T13:02:14.120Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    }
  }
}

Extras by type

email.bounced
{
  "type": "email.bounced",
  "created_at": "2026-10-24T13:02:15.004Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    },
    "bounce": {
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"],
      "message": "smtp; 550 5.1.1 user unknown",
      "subType": "General",
      "type": "Permanent"
    }
  }
}

bounce.type is Permanent (invalid address: do not retry), Transient (temporary problem) or Undetermined.

email.clicked
{
  "type": "email.clicked",
  "created_at": "2026-10-24T13:20:41.530Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    },
    "click": {
      "ipAddress": "203.0.113.24",
      "link": "https://acmestore.com/tracking/10482",
      "timestamp": "2026-10-24T13:20:41.000Z",
      "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)"
    }
  }
}
email.replied
{
  "type": "email.replied",
  "created_at": "2026-10-24T16:12:08.440Z",
  "data": {
    "email_id": "019a1f10-6c11-7d22-8e33-4f5a6b7c8d9e",
    "created_at": "2026-10-24T15:30:02.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Jane, Black Friday starts early for you",
    "tags": {
      "broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b"
    },
    "broadcast_id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
    "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "contact_external_id": "user_8421",
    "reply": {
      "from": "Jane Doe <[email protected]>",
      "subject": "Re: Jane, Black Friday starts early for you",
      "message_id": "[email protected]",
      "preview": "Hi! Can I change the size of my order before the sale starts?",
      "attachments": 0,
      "auto": false
    }
  }
}

reply.auto is true for automatic replies (out of office, autoresponders), which do not count as a real reply in automations. reply.preview carries the first 200 characters of the text and reply.attachments only the number of attachments. The reply arrives at the reply address Postal creates for the email and is forwarded to the reply_to you set.

email.failed
{
  "type": "email.failed",
  "created_at": "2026-10-24T13:02:12.300Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    },
    "failed": {
      "reason": "The sending domain is not verified."
    }
  }
}
email.suppressed
{
  "type": "email.suppressed",
  "created_at": "2026-10-24T13:02:12.300Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    },
    "suppressed": {
      "diagnosticCode": [],
      "message": "The recipient is on the suppression list: [email protected]",
      "reason": "previous_bounce",
      "type": "Suppressed"
    }
  }
}
email.received
{
  "type": "email.received",
  "created_at": "2026-10-24T12:41:09.880Z",
  "data": {
    "email_id": "019a1c00-3e4f-7a5b-9c6d-7e8f9a0b1c2d",
    "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
    "created_at": "2026-10-24T12:41:09.000Z",
    "from": "Carla Souza <[email protected]>",
    "to": ["[email protected]"],
    "cc": [],
    "subject": "Size exchange for order #10482",
    "message_id": "<[email protected]>",
    "attachments": [
      {
        "id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
        "filename": "label-photo.jpg",
        "content_type": "image/jpeg",
        "size": 15620
      }
    ]
  }
}

The event carries only a summary (sender, subject and attachments, without the body). To read the content, use GET /emails/receiving/{email_id}; to see the whole thread, GET /inboxes/{inbox_id}/threads/{thread_id}. The webhook covers all of the team's inboxes: filter by inbox_id if you only want to handle one. Select Received when you create the webhook in the dashboard (or include email.received in events).

domain.updated
{
  "type": "domain.updated",
  "created_at": "2026-10-02T18:31:44.210Z",
  "data": {
    "id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
    "name": "acmestore.com",
    "status": "verified",
    "created_at": "2026-10-02T18:20:00.000Z",
    "region": "sa-east-1",
    "records": [
      {
        "record": "DKIM",
        "name": "postal._domainkey",
        "value": "p=MIIBIjANBgkqh…IDAQAB",
        "type": "TXT",
        "ttl": "Auto",
        "status": "verified"
      }
    ]
  }
}

When a required record of an already verified domain can no longer be found, domain.updated also carries records_missing, with since, deadline_at and the records that are missing. See when a record goes missing.

suppression.added
{
  "type": "suppression.added",
  "created_at": "2026-10-24T13:02:15.400Z",
  "data": {
    "id": "019a1530-6d7e-7f80-9a1b-2c3d4e5f6a7b",
    "email": "[email protected]",
    "origin": "bounce",
    "source_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:15.000Z"
  }
}
contact.created
{
  "type": "contact.created",
  "created_at": "2026-10-24T14:00:00.310Z",
  "data": {
    "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "email": "[email protected]",
    "external_id": "user_8421",
    "user_id": "user_8421",
    "first_name": "Jane",
    "last_name": "Doe",
    "unsubscribed": false,
    "properties": {
      "plan": "pro"
    },
    "source": "api",
    "created_at": "2026-10-24T14:00:00.000Z",
    "updated_at": "2026-10-24T14:00:00.000Z"
  }
}

contact.updated and contact.deleted use the same format (in deleted, the contact as it was before being deleted). external_id is the contact's ID in your system (user_id is the same value under another name) and source says where the change came from: see the source of changes.

contact.segment_entered
{
  "type": "contact.segment_entered",
  "created_at": "2026-10-24T14:05:31.870Z",
  "data": {
    "segment": {
      "id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
      "name": "Active customers",
      "type": "dynamic"
    },
    "contact": {
      "id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
      "email": "[email protected]",
      "external_id": "user_8421",
      "user_id": "user_8421",
      "first_name": "Jane",
      "last_name": "Doe",
      "unsubscribed": false,
      "properties": {
        "plan": "pro"
      },
      "source": "api",
      "created_at": "2026-10-24T14:00:00.000Z",
      "updated_at": "2026-10-24T14:05:30.000Z"
    }
  }
}

contact.segment_exited has the same format. data carries the segment and the contact (in the format above). Both events apply to dynamic segments only, fire exactly once per entry or exit, and a newly created segment, or one whose filters changed, starts from a silent baseline: contacts that already match the filters do not generate segment_entered.

form.submitted
{
  "type": "form.submitted",
  "created_at": "2026-10-24T15:03:12.800Z",
  "data": {
    "id": "019a1e20-8091-7ca2-9d1e-3f4a5b6c7d8e",
    "form_id": "019a1da0-7f80-7b91-8c0d-2e3f4a5b6c7d",
    "form_name": "Newsletter",
    "contact_id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
    "email": "[email protected]",
    "data": {
      "email": "[email protected]",
      "first_name": "Jane",
      "properties": {}
    },
    "answers": {
      "email": "[email protected]",
      "first_name": "Jane"
    },
    "consent_given_at": "2026-10-24T15:00:00.000Z",
    "status": "subscribed",
    "created_at": "2026-10-24T15:00:00.000Z",
    "confirmed_at": "2026-10-24T15:03:12.000Z"
  }
}

answers holds the answers by the ID of each form block; consent_given_at is the moment of consent (or null).

broadcast.sent
{
  "type": "broadcast.sent",
  "created_at": "2026-10-22T12:04:31.220Z",
  "data": {
    "id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
    "name": "Black Friday: early access",
    "segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
    "topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
    "from": "Acme Store <[email protected]>",
    "subject": "{{{FIRST_NAME|Customer}}}, Black Friday starts early for you",
    "status": "sent",
    "recipients_count": 1247,
    "created_at": "2026-10-21T18:40:00.000Z",
    "sent_at": "2026-10-22T12:04:31.000Z"
  }
}

The emails to each recipient generate the usual email.* events, with the broadcast_id tag; those from automations carry automation_id and automation_run_id.

Sending pauses

sending.paused and sending.resumed have no email: data carries the state of the team's sending. Use them to alert your team the moment a pause happens, instead of finding out from the 403 responses.

sending.paused
{
  "type": "sending.paused",
  "created_at": "2026-10-24T15:20:02.310Z",
  "data": {
    "paused": true,
    "reason": "Bounce rate of 6.40% in the last 7 days (limit: 5.00%).",
    "reason_code": "bounce_rate",
    "reason_details": {
      "bounce_rate": 0.064,
      "limit": 0.05,
      "window_days": 7
    },
    "paused_at": "2026-10-24T15:20:02.000Z"
  }
}
FieldWhat it carries
pausedtrue in sending.paused and false in sending.resumed.
reasonThe reason as text for people, in the team's language: the one the team owner picked in the dashboard (pt-BR or en); English until they pick one. When Postal's team pauses sending by hand, it is the text they wrote, as written (without text, the standard sentence in the team's language). Pauses recorded before reason_code existed keep the Portuguese text they were saved with.
reason_codeThe reason as a stable code, the same in every language: bounce_rate (permanent bounce rate), complaint_rate (complaint rate), content_rejected (rate of emails refused for their content), all_bounce_rate (rate of bounces of every type, at levels 1 to 3 of the account reputation) or manual (Postal's team paused it). Treat a code you do not know as any other pause. null in sending.resumed and on old pauses.
reason_detailsThe numbers behind the reason. For bounce_rate: {"bounce_rate", "limit", "window_days"}; for complaint_rate: {"complaint_rate", "limit", "window_days"}; for content_rejected: {"content_rejected_rate", "limit", "window_days"}; for all_bounce_rate: {"all_bounce_rate", "limit", "window_days"}. Rates are fractions (0.0526 is 5.26%) and window_days is the window, in days, they were measured over. When the pause came from the last-24-hours window (levels 1 to 3), window_hours (24) takes the place of window_days. null for manual, in sending.resumed and on old pauses.
paused_atWhen the pause started (ISO 8601, UTC); null in sending.resumed.

To decide what to do, use paused and reason_code, never the text of reason: it exists to be shown to people and its wording can change. The current state, in the language of the request, is in GET /usage. In sending.resumed, paused is false and reason, reason_code, reason_details and paused_at are null.

Inbox threads and drafts

In inbox.thread.* events, data is the thread in the format of an item from GET /inboxes/{id}/threads (without object and without the messages): id, inbox_id, subject, folder, read, unread_count, message_count, snippet, participants, has_attachments, has_draft, has_sent, last_message_at, created_at, assigned_to and labels. In inbox.thread.updated it already carries the change, and in inbox.thread.deleted it is as it was before being deleted. A bulk action generates one inbox.thread.updated per thread that actually changed. To read the messages, use GET /inboxes/{inbox_id}/threads/{id}.

inbox.thread.updated
{
  "type": "inbox.thread.updated",
  "created_at": "2026-10-24T13:10:42.620Z",
  "data": {
    "id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
    "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "subject": "Size exchange for order #10482",
    "folder": "archive",
    "read": true,
    "unread_count": 0,
    "message_count": 2,
    "snippet": "Hi Carla! We have already set aside size M for you.",
    "participants": [
      {
        "name": "Carla Souza",
        "email": "[email protected]",
        "is_me": false
      },
      {
        "name": "Ada from Support",
        "email": "[email protected]",
        "is_me": true
      }
    ],
    "has_attachments": true,
    "has_draft": false,
    "has_sent": true,
    "last_message_at": "2026-10-24T13:02:11.000Z",
    "created_at": "2026-10-24T12:41:09.000Z",
    "assigned_to": {
      "id": 12,
      "name": "Carlos Lima",
      "email": "[email protected]",
      "avatar_url": null
    },
    "labels": [
      {
        "id": "019a1c01-0b21-7e32-9f43-1a2b3c4d5e6f",
        "name": "Important",
        "color": "orange"
      }
    ]
  }
}

inbox.thread.created and inbox.thread.deleted use the same format. In inbox.draft.created, data is the draft in the format of GET /inboxes/{id}/drafts/{id}, without object; because only the agent triggers this event, source is always "agent" and created_by is null.

inbox.draft.created
{
  "type": "inbox.draft.created",
  "created_at": "2026-10-24T12:41:14.205Z",
  "data": {
    "id": "019a1c02-1c32-7f43-8a54-2b3c4d5e6f70",
    "inbox_id": "0199f5c1-0a1b-7c2d-8e3f-4a5b6c7d8e9f",
    "thread_id": "019a1c00-5a10-7d11-8e22-0f1a2b3c4d5e",
    "source": "agent",
    "to": ["[email protected]"],
    "cc": [],
    "bcc": [],
    "subject": "Re: Size exchange for order #10482",
    "text": "Hi Carla! We will set aside size M and let you know as soon as it ships.",
    "html": null,
    "attachments": [],
    "created_by": null,
    "created_at": "2026-10-24T12:41:14.000Z",
    "updated_at": "2026-10-24T12:41:14.000Z"
  }
}

#Source of contact changes

The contact.created, contact.updated and contact.deleted events carry source, the origin of the change: api (an API key), dashboard, form, import, automation, system, crm:<connection id> (a CRM connection) or the label the API caller chose. The same origin is stored on the contact, in last_change_source, and filters GET /contacts with source (see the source in the contacts reference).

To label the changes one of your systems makes, send the X-Postal-Source header with the API key (or the source field in the body, where it exists, as in POST /contacts): X-Postal-Source: erp. The label has up to 64 characters (lowercase letters, numbers, ., _, : or -). The labels that describe Postal's own channels are reserved (dashboard, form, import, automation, partner, system, postal_lifecycle and anything starting with crm:) and answer 422 validation_error.

This is what prevents an echo between two systems that sync with each other: create the webhook with skip_sources (POST /webhooks), such as ["erp"], and it does not receive the changes the ERP itself made. A trailing * works as a prefix (crm:* skips every CRM connection). In contact.deleted, the source is that of whoever deleted the contact. The contact.segment_* events and all the others have no source and are not filtered.

#Verify the signature

Deliveries follow the Standard Webhooks specification and arrive with these headers:

HeaderContent
webhook-idMessage ID. It is the same on every attempt and replay: use it to discard duplicates.
webhook-timestampTime of sending, in seconds (Unix).
webhook-signatureSignature in the format v1,<base64>.
svix-id, svix-timestamp, svix-signatureThe same values, for libraries that read these names.

The signature is an HMAC-SHA256, Base64-encoded, of the text {webhook-id}.{webhook-timestamp}.{body}, using as the key the signing_secret without the whsec_ prefix and Base64-decoded. Compute it over the body exactly as it arrived (do not parse and serialize it again), compare in constant time, and reject timestamps more than 5 minutes off, to prevent replays.

Verification

Node.js

import crypto from 'node:crypto';
import express from 'express';

const app = express();
const secret = process.env.POSTAL_WEBHOOK_SECRET; // whsec_...

// The body must arrive raw (a Buffer) for the signature to match.
app.post('/webhooks/postal', express.raw({ type: 'application/json' }), (req, res) => {
  const id = req.get('webhook-id');
  const timestamp = req.get('webhook-timestamp');
  const signatures = (req.get('webhook-signature') || '').split(' ');

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('timestamp too old');
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.${req.body}`)
    .digest('base64');

  const valid = signatures.some((item) => {
    const [, value = ''] = item.split(',');
    return value.length === expected.length
      && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });

  if (!valid) {
    return res.status(401).send('invalid signature');
  }

  const event = JSON.parse(req.body);
  // handle event.type and event.data (preferably in a queue)
  res.sendStatus(200);
});

Python

import base64
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["POSTAL_WEBHOOK_SECRET"]  # whsec_...

@app.post("/webhooks/postal")
def postal_webhook():
    body = request.get_data()  # bytes, exactly as received
    msg_id = request.headers["webhook-id"]
    timestamp = request.headers["webhook-timestamp"]
    signatures = request.headers.get("webhook-signature", "").split(" ")

    if abs(time.time() - int(timestamp)) > 300:
        abort(400)

    key = base64.b64decode(SECRET.removeprefix("whsec_"))
    content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, content, hashlib.sha256).digest()).decode()

    if not any(hmac.compare_digest(item.partition(",")[2], expected) for item in signatures):
        abort(401)

    event = json.loads(body)
    # handle event["type"] and event["data"]
    return "", 200

PHP

<?php

$secret = getenv('POSTAL_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input');    // raw body
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signatures = explode(' ', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');

if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));

$valid = false;
foreach ($signatures as $signature) {
    [, $value] = array_pad(explode(',', $signature, 2), 2, '');
    $valid = $valid || hash_equals($expected, $value);
}

if (! $valid) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
// handle $event['type'] and $event['data']
http_response_code(200);

Any Standard Webhooks-compatible library works too, such as standardwebhooks (npm and PyPI).

#Responses and retries

Respond with any 2xx status within 15 seconds to acknowledge receipt. Redirects are not followed. Any other response, a timeout or a connection error counts as a failure, and Postal tries again after:

AttemptWait after the previous one
2nd5 seconds
3rd5 minutes
4th30 minutes
5th2 hours
6th5 hours
7th10 hours
8th10 hours

After 8 attempts (about 27 hours), the event is marked failed. See the attempts and replay any event from the dashboard or with POST /webhooks/{id}/events/{event_id}/replay. All attempts and replays of an event use the same webhook-id. Deliveries use the User-Agent Postal-Webhooks/1.0.

#Automatic disabling

An endpoint that only fails, with no successful delivery for 5 days, is disabled: the webhook gets status: "disabled" and disabled_reason: "failing", the events still waiting for a retry become failed, and the team owners and admins get an email. From then on, no events are sent to it.

  • failing_since shows since when deliveries have been failing (it goes back to null on the first successful delivery) and last_success_at the last one that worked. A single 2xx response resets the count.
  • After you fix your server, send a test event and re-enable the webhook from the dashboard (Re-enable) or with PATCH /webhooks/{id} and {"status": "enabled"}. Events from the disabled period are not resent automatically: replay the ones you need.
  • A webhook paused by a person has disabled_reason: "manual".

#Test event

In the dashboard, Send test event (or POST /webhooks/{id}/test) immediately sends a signed event, in a single attempt, even when the webhook is disabled:

webhook.test
{
  "type": "webhook.test",
  "created_at": "2026-10-24T13:10:00.000Z",
  "data": {
    "webhook_id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
    "message": "This is a test event sent from the Postal dashboard."
  }
}

It shows up in the deliveries, with the attempts and your server's response, and does not count toward automatic disabling. You do not need to subscribe to the webhook.test type.

#Testing with test emails

To see events reach your server without sending real email, send an email to the test addresses: [email protected], [email protected], [email protected], [email protected] or [email protected]. They produce real webhook deliveries, signed and with retries like those of any email (email.sent and email.delivered, email.bounced with a permanent bounce, email.complained, email.suppressed or email.delivery_delayed), but nothing is actually sent, no address goes on the suppression list and your domain's reputation does not change. Each address produces these events: delivered, email.sent and email.delivered; bounced, email.sent and email.bounced; complained, email.sent, email.delivered and email.complained; suppressed, only email.suppressed; delayed, email.sent and email.delivery_delayed. The first ones go out right away and the rest a few seconds later. You can add a label, such as [email protected], to tell your tests apart. Test addresses are part of the plan (test_addresses) and cannot be mixed with real recipients in the same email.

#History

Sent events and the attempts for each one are kept for 30 days in the dashboard and in GET /webhooks/{id}/events; after that they are deleted.

#Best practices

  • Respond 200 right away and process in the background (in a queue), so you do not blow the 15-second limit.
  • Keep the webhook-ids you have already processed: the same message can arrive more than once.
  • Do not rely on arrival order. Use the payload's created_at to sort.
  • Rotate the secret with POST /webhooks/{id}/signing-secret/rotate if it leaks.

#CRM and app webhooks

These are the webhooks Postal sends to your server. The opposite direction, a CRM or an app sending people's changes to Postal, has its own URL and uses the same signature: see CRM connection by webhook, CRM connections and Integrate your app. A CRM webhook connection also receives contact and email events from Postal, signed as above.