Webhooks
Endpoints de webhook, eventos entregados, intentos y reenvío.
Registra URLs para recibir eventos en tiempo real. Cómo verificar la firma, los formatos de cada evento y la política de reintentos están en la guía de Webhooks.
El número de webhooks lo da el plan (webhooks): crear uno por encima del límite responde 403 plan_limit_reached. Si el plan baja de lo que tiene el equipo, los webhooks más nuevos por encima del límite quedan con paused_by_plan: true: siguen en la lista y se pueden leer y eliminar, pero no reciben eventos y no se pueden modificar, probar ni rotar su secreto (403 paused_by_plan). Vuelven solos cuando el plan o el recuento lo permiten.
#Listar webhooks
GET/webhooks
Acepta limit, after y before. El secreto de firma no aparece en la lista. status es enabled o disabled y, si está desactivado, disabled_reason dice por qué: manual (pausado por una persona) o failing (desactivado por fallos seguidos).
cURL
curl https://api.postal.so/webhooks \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/webhooks",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('webhooks', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.delivered", "email.bounced", "email.complained"],
"skip_sources": [],
"status": "enabled",
"disabled_reason": null,
"disabled_at": null,
"failing_since": null,
"last_success_at": "2026-10-24T13:02:14.000000Z",
"created_at": "2026-10-03T10:00:00.000000Z"
}
]
}
#Crear un webhook
POST/webhooks
Registra el endpoint y devuelve el signing_secret (whsec_…) usado para firmar las entregas.
Cuerpo de la solicitud
endpointstringobligatorioURL HTTPS pública, hasta 2048 caracteres. Las direcciones de red privada se rechazan en la entrega.eventsstring[]obligatorioUno o más tipos de evento, sin repetir:email.sent,email.delivered,email.delivery_delayed,email.bounced,email.complained,email.opened,email.clicked,email.replied,email.failed,email.scheduled,email.suppressed,email.received,domain.created,domain.updated,domain.deleted,suppression.added,suppression.removed,contact.created,contact.updated,contact.deleted,contact.segment_entered,contact.segment_exited,form.submitted,broadcast.sent,sending.paused,sending.resumed,inbox.thread.created,inbox.thread.updated,inbox.thread.deleted,inbox.draft.created. Cuándo ocurre cada uno: guía de webhooks.skip_sourcesstring[]Hasta 20 orígenes de cambio que este endpoint no debe recibir en los eventoscontact.created,contact.updatedycontact.deleted. Cada origen tiene hasta 64 caracteres (letras minúsculas, números,.,_,:o-) y puede terminar en*para valer como prefijo, comocrm:*. Sirve para que un sistema que sincroniza en los dos sentidos no reciba de vuelta los cambios que él mismo hizo: mira el origen de los cambios.
Errores
- 403
plan_limit_reachedEl equipo ya tiene el número de webhooks que permite el plan. - 422
validation_errorURL que no es HTTPS, lista vacía, evento desconocido o repetido, u origen con formato no válido.
cURL
curl -X POST https://api.postal.so/webhooks \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.delivered", "email.bounced", "email.complained"]
}'
Node.js
const response = await fetch('https://api.postal.so/webhooks', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: 'https://lojaexemplo.com.br/webhooks/postal',
events: ['email.delivered', 'email.bounced', 'email.complained'],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/webhooks",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.delivered", "email.bounced", "email.complained"],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('webhooks', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'endpoint' => 'https://lojaexemplo.com.br/webhooks/postal',
'events' => ['email.delivered', 'email.bounced', 'email.complained'],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"signing_secret": "whsec_c2VncmVkby1kZS1leGVtcGxvLTEyMzQ1Ng=="
}
#Obtener un webhook
GET/webhooks/{id}
Devuelve el webhook con el signing_secret actual.
Parámetros de ruta
idstringobligatorioID del webhook.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"endpoint": "https://lojaexemplo.com.br/webhooks/postal",
"events": ["email.delivered", "email.bounced", "email.complained"],
"skip_sources": [],
"status": "enabled",
"disabled_reason": null,
"disabled_at": null,
"failing_since": null,
"last_success_at": "2026-10-24T13:02:14.000000Z",
"created_at": "2026-10-03T10:00:00.000000Z",
"signing_secret": "whsec_c2VncmVkby1kZS1leGVtcGxvLTEyMzQ1Ng=="
}
#Actualizar un webhook
PATCH/webhooks/{id}
Cambia la URL o los eventos, o pausa el webhook. Con disabled, no se entrega ningún evento (ni los reintentos pendientes) y disabled_reason queda en manual. {"status": "enabled"} reactiva un webhook pausado o desactivado por fallos y limpia disabled_reason y failing_since.
Parámetros de ruta
idstringobligatorioID del webhook.
Cuerpo de la solicitud
endpointstringNueva URL HTTPS.eventsstring[]Nueva lista de eventos (sustituye a la anterior).skip_sourcesstring[] | nullNueva lista de orígenes que ignorar (mismas reglas de Crear un webhook). Envía[]onullpara vaciarla.statusstringenabledodisabled.
Errores
- 403
paused_by_planEl webhook está pausado porque el equipo superó el límite del plan. Elimina otros webhooks o cambia de plan. - 422
validation_errorAlgún campo no es válido. El mensaje indica cuál. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X PATCH https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "disabled"
}'
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
status: 'disabled',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"status": "disabled",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'status' => 'disabled',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b"
}
#Eliminar un webhook
DELETE/webhooks/{id}
Parámetros de ruta
idstringobligatorioID del webhook.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X DELETE https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"deleted": true
}
#Rotar el secreto de firma
POST/webhooks/{id}/signing-secret/rotate
Genera un nuevo signing_secret, que vale de inmediato. Actualiza tu servidor enseguida: las entregas firmadas con el secreto nuevo fallan en la verificación con el antiguo.
Parámetros de ruta
idstringobligatorioID del webhook.
Errores
- 403
paused_by_planEl webhook está pausado por el plan. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X POST https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/signing-secret/rotate \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/signing-secret/rotate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/signing-secret/rotate",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/signing-secret/rotate', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook",
"id": "0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b",
"signing_secret": "whsec_bm92by1zZWdyZWRvLWRlLWV4ZW1wbG8tOTg3Ng=="
}
#Enviar un evento de prueba
POST/webhooks/{id}/test
Envía al instante un evento webhook.test al endpoint, firmado como los demás, para que compruebes la URL y la verificación de la firma. Es un solo intento, sin reintentos, y vale incluso con el webhook desactivado. Aparece en la lista de eventos y no cuenta para la desactivación automática.
Parámetros de ruta
idstringobligatorioID del webhook.
Errores
- 403
paused_by_planEl webhook está pausado por el plan. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X POST https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/test \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/test",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/test', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook_event",
"id": "019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c",
"type": "webhook.test"
}
#Listar eventos de un webhook
GET/webhooks/{id}/events
Los eventos enviados a este webhook en los últimos 30 días, con el estado de la entrega: pending, attempting (falló y tiene un nuevo intento en next_attempt_at), success o failed (agotó los intentos o el webhook se desactivó). Acepta limit, after y before.
Parámetros de ruta
idstringobligatorioID del webhook.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": true,
"data": [
{
"object": "webhook_event",
"id": "019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c",
"type": "email.delivered",
"created_at": "2026-10-24T13:02:14.000000Z",
"status": "success",
"attempts": 1,
"next_attempt_at": null
}
]
}
#Obtener un evento
GET/webhooks/{id}/events/{event_id}
Devuelve el evento con el payload exacto que se envió (o se enviará).
Parámetros de ruta
idstringobligatorioID del webhook.event_idstringobligatorioID del evento.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook_event",
"id": "019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c",
"type": "email.delivered",
"created_at": "2026-10-24T13:02:14.000000Z",
"status": "success",
"attempts": 1,
"next_attempt_at": null,
"payload": {
"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"
}
}
}
}
#Listar intentos de entrega
GET/webhooks/{id}/events/{event_id}/attempts
Cada intento de entrega con el estado HTTP, el inicio de la respuesta de tu servidor (hasta 2.000 caracteres), el error de conexión, si lo hubo, y la duración.
Parámetros de ruta
idstringobligatorioID del webhook.event_idstringobligatorioID del evento.
Errores
- 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/attempts \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/attempts', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/attempts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/attempts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"id": "019a1b2c-5e70-7b91-8c2d-3e4f5a6b7c8d",
"http_status_code": 200,
"response": "{\"ok\":true}",
"error": null,
"duration_ms": 184,
"sent_at": "2026-10-24T13:02:14.000000Z"
}
]
}
#Reenviar un evento
POST/webhooks/{id}/events/{event_id}/replay
Entrega el evento de nuevo al instante, aunque ya haya salido bien o haya fallado definitivamente. El contador de intentos se reinicia. La entrega usa el mismo ID de mensaje (webhook-id), así que tu servidor puede reconocer la repetición. Con el webhook desactivado no se entrega nada: reactívalo antes, con PATCH /webhooks/{id}.
Parámetros de ruta
idstringobligatorioID del webhook.event_idstringobligatorioID del evento.
Errores
- 403
paused_by_planEl webhook está pausado por el plan. - 404
not_foundEl recurso no existe o es de otro equipo.
cURL
curl -X POST https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/replay \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/replay', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/replay",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('webhooks/0199f6d2-3c4d-7e5f-8a6b-7c8d9e0f1a2b/events/019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c/replay', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "webhook_event",
"id": "019a1b2c-5e6f-7a80-9b1c-2d3e4f5a6b7c"
}