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
| Event | When it happens |
|---|---|
email.sent | Postal handed the email to the sending provider. |
email.delivered | The recipient's server accepted the message. |
email.delivery_delayed | Delivery was delayed (full mailbox, server unavailable…). Retries happen automatically. |
email.bounced | The email bounced. On permanent bounces, the address goes on the suppression list. |
email.complained | The recipient marked it as spam. The address goes on the suppression list. |
email.opened | The email was opened (open tracking is on). |
email.clicked | A link was clicked (click tracking is on). |
email.replied | Someone 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.failed | The email could not be sent. failed.reason gives the reason. |
email.scheduled | The email was scheduled. |
email.suppressed | Recipients were on the suppression list and were removed from the send. |
email.received | An inbox received a message. The code from an OTP-Token header is not included in the payload. |
domain.created | A domain was added. |
domain.updated | A domain changed (verification status, tracking, capabilities). |
domain.deleted | A domain was removed (including when another account claimed it). |
suppression.added | An address was added to the suppression list. |
suppression.removed | An address was removed from the suppression list. |
contact.created | A contact was created (through the API, an import or a form). |
contact.updated | A contact changed (data, properties or unsubscribe). |
contact.deleted | A contact was deleted. |
contact.segment_entered | A contact became part of a dynamic segment because it now matches the filters. Static segments do not generate the event. |
contact.segment_exited | A contact left a dynamic segment because it no longer matches the filters. |
form.submitted | Someone signed up through a form and joined the audience (after confirmation, with double opt-in). |
broadcast.sent | A broadcast finished sending: all recipients were processed. |
sending.paused | The 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.resumed | The team's sending was resumed. |
inbox.thread.created | A new thread was opened in an inbox, by a received email or by a message sent from the inbox. |
inbox.thread.updated | The 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.deleted | A thread was permanently deleted (from the trash). |
inbox.draft.created | An 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.
{
"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
{
"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.
{
"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)"
}
}
}
{
"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.
{
"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."
}
}
}
{
"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"
}
}
}
{
"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).
{
"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.
{
"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"
}
}
{
"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.
{
"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.
{
"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).
{
"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.
{
"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"
}
}
| Field | What it carries |
|---|---|
paused | true in sending.paused and false in sending.resumed. |
reason | The 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_code | The 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_details | The 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_at | When 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}.
{
"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.
{
"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:
| Header | Content |
|---|---|
webhook-id | Message ID. It is the same on every attempt and replay: use it to discard duplicates. |
webhook-timestamp | Time of sending, in seconds (Unix). |
webhook-signature | Signature in the format v1,<base64>. |
svix-id, svix-timestamp, svix-signature | The 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.
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:
| Attempt | Wait after the previous one |
|---|---|
| 2nd | 5 seconds |
| 3rd | 5 minutes |
| 4th | 30 minutes |
| 5th | 2 hours |
| 6th | 5 hours |
| 7th | 10 hours |
| 8th | 10 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_sinceshows since when deliveries have been failing (it goes back tonullon the first successful delivery) andlast_success_atthe last one that worked. A single2xxresponse 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:
{
"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
200right 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_atto sort. - Rotate the secret with
POST /webhooks/{id}/signing-secret/rotateif 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.