Webhooks
Recibe en tu servidor eventos de entrega, rebotes, clics, correos recibidos y cambios de dominio, en tiempo real y firmados.
#Configurar
Registra la URL que recibirá los eventos en el panel, en Webhooks, o con POST /webhooks, eligiendo los eventos. La URL debe ser HTTPS y pública. Guarda el signing_secret (whsec_…): sirve para comprobar que la solicitud vino de Postal. El número de endpoints depende del plan (consulta Límites).
#Eventos
| Evento | Cuándo ocurre |
|---|---|
email.sent | Postal entregó el correo al proveedor de envío. |
email.delivered | El servidor del destinatario aceptó el mensaje. |
email.delivery_delayed | La entrega se aplazó (buzón lleno, servidor no disponible…). Los nuevos intentos ocurren solos. |
email.bounced | El correo rebotó. En rebotes permanentes, la dirección pasa a la lista de supresión. |
email.complained | El destinatario lo marcó como spam. La dirección pasa a la lista de supresión. |
email.opened | El correo se abrió (seguimiento de aperturas activado). |
email.clicked | Se hizo clic en un enlace (seguimiento de clics activado). |
email.replied | Alguien respondió a un correo enviado con detección de respuestas (detect_replies en una campaña o en un paso de envío de una automatización). Las respuestas automáticas, como las de fuera de la oficina, llegan con reply.auto: true. |
email.failed | No se pudo enviar. failed.reason indica el motivo. |
email.scheduled | El correo se programó. |
email.suppressed | Los destinatarios estaban en la lista de supresión y se quitaron del envío. |
email.received | Un buzón de entrada recibió un mensaje. El código de un encabezado OTP-Token no va en el payload. |
domain.created | Se registró un dominio. |
domain.updated | Un dominio cambió (estado de la verificación, seguimiento, capacidades). |
domain.deleted | Se eliminó un dominio (también cuando otra cuenta lo reclamó). |
suppression.added | Una dirección entró en la lista de supresión. |
suppression.removed | Una dirección salió de la lista de supresión. |
contact.created | Se creó un contacto (por la API, por importación o por formulario). |
contact.updated | Un contacto cambió (datos, propiedades o baja). |
contact.deleted | Se eliminó un contacto. |
contact.segment_entered | Un contacto pasó a formar parte de un segmento dinámico, porque ahora cumple los filtros. Los segmentos estáticos no generan el evento. |
contact.segment_exited | Un contacto dejó de formar parte de un segmento dinámico, porque ya no cumple los filtros. |
form.submitted | Alguien se suscribió mediante un formulario y entró en la audiencia (tras la confirmación, con double opt-in). |
broadcast.sent | Una campaña terminó de enviarse: todos los destinatarios fueron procesados. |
sending.paused | Los envíos del equipo se pausaron por una tasa alta de rebotes o quejas (consulta Límites). A partir de ahí los envíos reciben 403 sending_paused. El data trae el motivo en reason (en el idioma del equipo), reason_code y reason_details: consulta Pausa de envíos. |
sending.resumed | Los envíos del equipo se liberaron de nuevo. |
inbox.thread.created | Se abrió una conversación nueva en un buzón de entrada, por un correo recibido o por un mensaje enviado desde el buzón. |
inbox.thread.updated | Cambiaron la carpeta, el estado de leído, el responsable o las etiquetas de una conversación (PATCH, acción en lote, papelera o agente de IA), o un correo nuevo la sacó del archivo. Un evento por cambio. |
inbox.thread.deleted | Una conversación se borró definitivamente (desde la papelera). |
inbox.draft.created | El agente de IA de un buzón de entrada creó un borrador de respuesta. Los borradores creados por personas no generan evento. |
#Formato del payload
Cada entrega es un POST con un JSON de tres campos: type, created_at y data. En los eventos de correo, data trae el correo y, según el tipo, un objeto extra. Las tags vienen como objeto {"category": "shipping"} (nombre de la etiqueta → valor). Si el correo salió de una campaña, data trae también broadcast_id; si está vinculado a un contacto (campañas y automatizaciones), contact_id y 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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"tags": {
"category": "shipping"
}
}
}
Extras por tipo
{
"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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"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 es Permanent (dirección no válida: no lo intentes de nuevo), Transient (problema temporal) o 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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"tags": {
"category": "shipping"
},
"click": {
"ipAddress": "203.0.113.24",
"link": "https://lojaexemplo.com.br/rastreio/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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Ana, a Black Friday começa mais cedo para você",
"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": "Ana Souza <[email protected]>",
"subject": "Re: Ana, a Black Friday começa mais cedo para você",
"message_id": "[email protected]",
"preview": "Oi! Posso trocar o tamanho do meu pedido antes da promoção?",
"attachments": 0,
"auto": false
}
}
}
reply.auto es true en las respuestas automáticas (fuera de la oficina, autoresponders), que no cuentan como respuesta real en las automatizaciones. reply.preview trae los primeros 200 caracteres del texto y reply.attachments, solo la cantidad de adjuntos. La respuesta llega a la dirección de respuesta que Postal crea para el correo y se reenvía al reply_to que definiste.
{
"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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"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": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"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": "Troca de tamanho do pedido #10482",
"message_id": "<[email protected]>",
"attachments": [
{
"id": "019a1c00-3e50-7b6c-8d7e-9f0a1b2c3d4e",
"filename": "label-photo.jpg",
"content_type": "image/jpeg",
"size": 15620
}
]
}
}
El evento trae solo el resumen (remitente, asunto y adjuntos, sin el cuerpo). Para leer el contenido, usa GET /emails/receiving/{email_id}; para ver la conversación completa, GET /inboxes/{inbox_id}/threads/{thread_id}. El webhook vale para todos los buzones de entrada del equipo: filtra por el inbox_id si quieres tratar solo uno. Marca Recibido al crear el webhook en el panel (o incluye email.received en events).
{
"type": "domain.updated",
"created_at": "2026-10-02T18:31:44.210Z",
"data": {
"id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
"name": "lojaexemplo.com.br",
"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"
}
]
}
}
Cuando un registro obligatorio de un dominio ya verificado deja de encontrarse, domain.updated trae también records_missing, con since, deadline_at y los records que faltan. Consulta cuando un registro desaparece.
{
"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": "Ana",
"last_name": "Souza",
"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 y contact.deleted traen el mismo formato (en deleted, el contacto tal como estaba antes de eliminarse). external_id es el ID del contacto en tu sistema (user_id es el mismo valor, con otro nombre) y source indica de dónde vino el cambio: consulta el origen de los cambios.
{
"type": "contact.segment_entered",
"created_at": "2026-10-24T14:05:31.870Z",
"data": {
"segment": {
"id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"name": "Clientes ativos",
"type": "dynamic"
},
"contact": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_8421",
"user_id": "user_8421",
"first_name": "Ana",
"last_name": "Souza",
"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 tiene el mismo formato. data trae el segment y el contact (en el formato anterior). Los dos eventos valen solo para segmentos dinámicos, se disparan una única vez por entrada o salida, y un segmento recién creado o con filtros modificados parte de una línea base silenciosa: quien ya cumple los filtros no genera 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": "Ana",
"properties": {}
},
"answers": {
"email": "[email protected]",
"first_name": "Ana"
},
"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"
}
}
En answers, las respuestas por el ID de cada bloque del formulario; consent_given_at es el momento del consentimiento (o null).
{
"type": "broadcast.sent",
"created_at": "2026-10-22T12:04:31.220Z",
"data": {
"id": "019a1f00-2c3d-7e4f-8a5b-6c7d8e9f0a1b",
"name": "Black Friday: acesso antecipado",
"segment_id": "019a1d90-3b4c-7d5e-9f6a-7b8c9d0e1f2a",
"topic_id": "019a1d95-4c5d-7e6f-8a7b-8c9d0e1f2a3b",
"from": "Loja Exemplo <[email protected]>",
"subject": "{{{FIRST_NAME|Cliente}}}, a Black Friday começa mais cedo para você",
"status": "sent",
"recipients_count": 1247,
"created_at": "2026-10-21T18:40:00.000Z",
"sent_at": "2026-10-22T12:04:31.000Z"
}
}
Los correos de cada destinatario generan los eventos email.* normales, con la etiqueta broadcast_id; los de las automatizaciones, con automation_id y automation_run_id.
Pausa de envíos
sending.paused y sending.resumed no tienen correo: data trae el estado de los envíos del equipo. Úsalos para avisar a tu equipo en el momento en que ocurre la pausa, en lugar de enterarte por los 403.
{
"type": "sending.paused",
"created_at": "2026-10-24T15:20:02.310Z",
"data": {
"paused": true,
"reason": "Taxa de bounce de 6,40% nos últimos 7 dias (limite: 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"
}
}
| Campo | Qué trae |
|---|---|
paused | true en sending.paused y false en sending.resumed. |
reason | El motivo en texto para personas, en el idioma del equipo: el que el propietario del equipo eligió en el panel (pt-BR o en); mientras no elige, en inglés. Cuando el equipo de Postal pausa los envíos a mano, es el texto que escribió, tal como se escribió (sin texto, la frase predeterminada en el idioma del equipo). Las pausas registradas antes de existir reason_code mantienen el texto en portugués con que se guardaron. |
reason_code | El motivo como código estable, igual en cualquier idioma: bounce_rate (tasa de rebotes permanentes), complaint_rate (tasa de quejas), content_rejected (tasa de correos rechazados por su contenido), all_bounce_rate (tasa de rebotes de todos los tipos, en los niveles 1 a 3 de la reputación de la cuenta) o manual (el equipo de Postal pausó). Trata un código que no conozcas como una pausa cualquiera. null en sending.resumed y en las pausas antiguas. |
reason_details | Los números detrás del motivo. En bounce_rate: {"bounce_rate", "limit", "window_days"}; en complaint_rate: {"complaint_rate", "limit", "window_days"}; en content_rejected: {"content_rejected_rate", "limit", "window_days"}; en all_bounce_rate: {"all_bounce_rate", "limit", "window_days"}. Las tasas son fracciones (0.0526 es 5,26 %) y window_days es la ventana, en días, en que se midieron. Cuando la pausa vino de la ventana de las últimas 24 horas (niveles 1 a 3), window_hours (24) ocupa el lugar de window_days. null en manual, en sending.resumed y en las pausas antiguas. |
paused_at | Cuándo empezó la pausa (ISO 8601, UTC); null en sending.resumed. |
Para decidir qué hacer, usa paused y reason_code, nunca el texto de reason: existe para mostrarse a personas y puede cambiar de palabras. El estado actual, en el idioma de la solicitud, está en GET /usage. En sending.resumed, paused es false y reason, reason_code, reason_details y paused_at son null.
Conversaciones y borradores de los buzones de entrada
En los eventos inbox.thread.*, data es la conversación en el formato de un elemento de GET /inboxes/{id}/threads (sin object y sin los mensajes): 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 y labels. En inbox.thread.updated ya viene con el cambio aplicado y en inbox.thread.deleted como estaba antes de borrarse. Una acción en lote genera un inbox.thread.updated por cada conversación que realmente cambió. Para leer los mensajes, usa 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": "Troca de tamanho do pedido #10482",
"folder": "archive",
"read": true,
"unread_count": 0,
"message_count": 2,
"snippet": "Oi, Carla! Já separamos o tamanho M para você.",
"participants": [
{
"name": "Carla Souza",
"email": "[email protected]",
"is_me": false
},
{
"name": "Ada do Suporte",
"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": "Importante",
"color": "orange"
}
]
}
}
inbox.thread.created e inbox.thread.deleted tienen el mismo formato. En inbox.draft.created, data es el borrador en el formato de GET /inboxes/{id}/drafts/{id}, sin object; como solo el agente dispara el evento, source es siempre "agent" y created_by es 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: Troca de tamanho do pedido #10482",
"text": "Oi, Carla! Vamos separar o tamanho M e já te avisamos quando o envio sair.",
"html": null,
"attachments": [],
"created_by": null,
"created_at": "2026-10-24T12:41:14.000Z",
"updated_at": "2026-10-24T12:41:14.000Z"
}
}
#Origen de los cambios en los contactos
Los eventos contact.created, contact.updated y contact.deleted traen source, el origen del cambio: api (una clave de API), dashboard, form (formulario), import, automation, system, crm:<id da conexão> (una conexión de CRM) o la etiqueta que eligió quien llamó a la API. El mismo origen queda en el contacto, en last_change_source, y filtra GET /contacts con source (consulta el origen en la referencia de contactos).
Para dar un rótulo a los cambios que hace un sistema tuyo, envía el encabezado X-Postal-Source con la clave de API (o el campo source en el cuerpo, donde exista, como en POST /contacts): X-Postal-Source: erp. El rótulo admite hasta 64 caracteres (letras minúsculas, números, ., _, : o -). Los rótulos que describen los canales propios de Postal están reservados (dashboard, form, import, automation, partner, system, postal_lifecycle y todo lo que empieza por crm:) y devuelven 422 validation_error.
Es lo que evita el eco entre dos sistemas que se sincronizan: crea el webhook con skip_sources (POST /webhooks), como ["erp"], y no recibirá los cambios que haya hecho el propio ERP. Un * al final vale como prefijo (crm:* ignora todas las conexiones de CRM). En contact.deleted, el origen es el de quien eliminó el contacto. Los eventos contact.segment_* y los demás no tienen source y no se filtran.
#Verificar la firma
Las entregas siguen el estándar Standard Webhooks y llegan con estos encabezados:
| Encabezado | Contenido |
|---|---|
webhook-id | ID del mensaje. Es el mismo en todos los intentos y en los reenvíos: úsalo para descartar duplicados. |
webhook-timestamp | Momento del envío, en segundos (Unix). |
webhook-signature | Firma con el formato v1,<base64>. |
svix-id, svix-timestamp, svix-signature | Los mismos valores, para librerías que leen estos nombres. |
La firma es un HMAC-SHA256, en Base64, del texto {webhook-id}.{webhook-timestamp}.{body}, usando como clave el signing_secret sin el prefijo whsec_ y decodificado de Base64. Calcúlala sobre el cuerpo exactamente como llegó (sin hacer parse y volver a serializar), compárala en tiempo constante y rechaza los timestamps con más de 5 minutos de diferencia, para evitar repeticiones.
Node.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.POSTAL_WEBHOOK_SECRET; // whsec_...
// O corpo precisa chegar cru (Buffer) para a assinatura bater.
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 antigo');
}
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('assinatura inválida');
}
const event = JSON.parse(req.body);
// processe event.type e event.data (de preferência numa fila)
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, exatamente como chegaram
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)
# processe event["type"] e event["data"]
return "", 200
PHP
<?php
$secret = getenv('POSTAL_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // corpo cru
$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);
// processe $event['type'] e $event['data']
http_response_code(200);
Cualquier librería compatible con Standard Webhooks también funciona, como standardwebhooks (npm y PyPI).
#Respuestas y reintentos
Responde con cualquier estado 2xx en un máximo de 15 segundos para confirmar la recepción. No se siguen las redirecciones. Cualquier otra respuesta, timeout o error de conexión cuenta como fallo, y Postal lo intenta de nuevo después de:
| Intento | Espera tras el anterior |
|---|---|
| 2ª | 5 segundos |
| 3ª | 5 minutos |
| 4ª | 30 minutos |
| 5ª | 2 horas |
| 6ª | 5 horas |
| 7ª | 10 horas |
| 8ª | 10 horas |
Tras 8 intentos (unas 27 horas), el evento queda como failed. Consulta los intentos y reenvía cualquier evento desde el panel o con POST /webhooks/{id}/events/{event_id}/replay. Todos los intentos y reenvíos de un evento usan el mismo webhook-id. Las entregas usan el User-Agent Postal-Webhooks/1.0.
#Desactivación automática
Un endpoint que solo falla, sin ninguna entrega correcta durante 5 días, se desactiva: el webhook queda con status: "disabled" y disabled_reason: "failing", los eventos que aún esperaban un nuevo intento pasan a failed y los propietarios y administradores del equipo reciben un correo electrónico. A partir de ahí, no se le envía ningún evento.
failing_sinceindica desde cuándo fallan las entregas (vuelve anullcon la primera entrega correcta) ylast_success_at, la última que salió bien. Una sola respuesta2xxreinicia la cuenta.- Cuando hayas corregido el servidor, envía un evento de prueba y reactívalo desde el panel (Reactivar) o con
PATCH /webhooks/{id}y{"status": "enabled"}. Los eventos del periodo en que estuvo desactivado no se reenvían solos: reenvía los que necesites. - Un webhook pausado por alguien queda con
disabled_reason: "manual".
#Evento de prueba
En el panel, Enviar evento de prueba (o POST /webhooks/{id}/test) manda al instante un evento firmado, en un solo intento, incluso con el webhook desactivado:
{
"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."
}
}
Aparece en las entregas, con los intentos y la respuesta de tu servidor, y no cuenta para la desactivación automática. No hace falta suscribirse al tipo webhook.test.
#Probar con correos de prueba
Para ver los eventos llegar a tu servidor sin enviar correo electrónico de verdad, manda un correo a las direcciones de prueba: [email protected], [email protected], [email protected], [email protected] o [email protected]. Generan entregas de webhook reales, firmadas y con reintentos como las de cualquier correo (email.sent y email.delivered, email.bounced con un rebote permanente, email.complained, email.suppressed o email.delivery_delayed), pero no se envía nada, ninguna dirección entra en la lista de supresión y la reputación de tu dominio no cambia. Cada dirección genera estos eventos: delivered, email.sent y email.delivered; bounced, email.sent y email.bounced; complained, email.sent, email.delivered y email.complained; suppressed, solo email.suppressed; delayed, email.sent y email.delivery_delayed. Los primeros salen al instante y el resto, unos segundos después. Puedes añadir un rótulo, como [email protected], para separar tus pruebas. Las direcciones de prueba forman parte del plan (test_addresses) y no se mezclan con destinatarios reales en el mismo correo.
#Historial
Los eventos enviados y los intentos de cada uno se conservan 30 días en el panel y en GET /webhooks/{id}/events; después se eliminan.
#Buenas prácticas
- Responde
200enseguida y procesa en segundo plano (en una cola), para no pasarte de los 15 segundos. - Guarda los
webhook-idya procesados: el mismo mensaje puede llegar más de una vez. - No dependas del orden de llegada. Usa
created_atdel payload para ordenar. - Cambia el secreto con
POST /webhooks/{id}/signing-secret/rotatesi se filtra.
#Webhooks de CRM y de apps
Estos son los webhooks que Postal envía a tu servidor. El camino inverso, un CRM o una app que manda a Postal cambios de personas, tiene su propia URL y usa la misma firma: consulta Conexión de CRM por webhook, Conexiones de CRM e Integra tu app. Una conexión de CRM por webhook también recibe de Postal los eventos de contacto y de correo, firmados como arriba.