Iniciar sesión Crear cuenta
Guías

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

EventoCuándo ocurre
email.sentPostal entregó el correo al proveedor de envío.
email.deliveredEl servidor del destinatario aceptó el mensaje.
email.delivery_delayedLa entrega se aplazó (buzón lleno, servidor no disponible…). Los nuevos intentos ocurren solos.
email.bouncedEl correo rebotó. En rebotes permanentes, la dirección pasa a la lista de supresión.
email.complainedEl destinatario lo marcó como spam. La dirección pasa a la lista de supresión.
email.openedEl correo se abrió (seguimiento de aperturas activado).
email.clickedSe hizo clic en un enlace (seguimiento de clics activado).
email.repliedAlguien 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.failedNo se pudo enviar. failed.reason indica el motivo.
email.scheduledEl correo se programó.
email.suppressedLos destinatarios estaban en la lista de supresión y se quitaron del envío.
email.receivedUn buzón de entrada recibió un mensaje. El código de un encabezado OTP-Token no va en el payload.
domain.createdSe registró un dominio.
domain.updatedUn dominio cambió (estado de la verificación, seguimiento, capacidades).
domain.deletedSe eliminó un dominio (también cuando otra cuenta lo reclamó).
suppression.addedUna dirección entró en la lista de supresión.
suppression.removedUna dirección salió de la lista de supresión.
contact.createdSe creó un contacto (por la API, por importación o por formulario).
contact.updatedUn contacto cambió (datos, propiedades o baja).
contact.deletedSe eliminó un contacto.
contact.segment_enteredUn 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_exitedUn contacto dejó de formar parte de un segmento dinámico, porque ya no cumple los filtros.
form.submittedAlguien se suscribió mediante un formulario y entró en la audiencia (tras la confirmación, con double opt-in).
broadcast.sentUna campaña terminó de enviarse: todos los destinatarios fueron procesados.
sending.pausedLos 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.resumedLos envíos del equipo se liberaron de nuevo.
inbox.thread.createdSe 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.updatedCambiaron 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.deletedUna conversación se borró definitivamente (desde la papelera).
inbox.draft.createdEl 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.

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": "Loja Exemplo <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Seu pedido #10482 saiu para entrega",
    "tags": {
      "category": "shipping"
    }
  }
}

Extras por tipo

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": "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.

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": "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)"
    }
  }
}
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": "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.

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": "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."
    }
  }
}
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": "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"
    }
  }
}
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": "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).

domain.updated
{
  "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.

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": "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.

contact.segment_entered
{
  "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.

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": "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).

broadcast.sent
{
  "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.

sending.paused
{
  "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"
  }
}
CampoQué trae
pausedtrue en sending.paused y false en sending.resumed.
reasonEl 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_codeEl 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_detailsLos 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_atCuá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}.

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": "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.

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

EncabezadoContenido
webhook-idID del mensaje. Es el mismo en todos los intentos y en los reenvíos: úsalo para descartar duplicados.
webhook-timestampMomento del envío, en segundos (Unix).
webhook-signatureFirma con el formato v1,<base64>.
svix-id, svix-timestamp, svix-signatureLos 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.

Verificación

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:

IntentoEspera 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_since indica desde cuándo fallan las entregas (vuelve a null con la primera entrega correcta) y last_success_at, la última que salió bien. Una sola respuesta 2xx reinicia 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:

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."
  }
}

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 200 enseguida y procesa en segundo plano (en una cola), para no pasarte de los 15 segundos.
  • Guarda los webhook-id ya procesados: el mismo mensaje puede llegar más de una vez.
  • No dependas del orden de llegada. Usa created_at del payload para ordenar.
  • Cambia el secreto con POST /webhooks/{id}/signing-secret/rotate si 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.