Webhooks API
Webhook endpoints, the events delivered to them, delivery attempts and replay.
Register URLs to receive events in real time. How to verify the signature, the format of each event and the retry policy are in the Webhooks guide.
The number of webhooks comes from the plan (webhooks): creating one beyond the limit answers 403 plan_limit_reached. If the plan drops below what the team has, the newest webhooks above the limit get paused_by_plan: true: they stay in the list and can be read and deleted, but they receive no events and cannot be changed, tested or have their secret rotated (403 paused_by_plan). They come back on their own when the plan or the count allows it.
#List webhooks
GET/webhooks
Accepts limit, after and before. The signing secret does not appear in the list. status is enabled or disabled and, when disabled, disabled_reason says why: manual (paused by a person) or failing (disabled after repeated failures).
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://acmestore.com/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"
}
]
}
#Create a webhook
POST/webhooks
Registers the endpoint and returns the signing_secret (whsec_…) used to sign the deliveries.
Request body
endpointstringrequiredPublic HTTPS URL, up to 2048 characters. Private network addresses are refused at delivery.eventsstring[]requiredOne or more event types, without repeats: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. When each one happens: the webhooks guide.skip_sourcesstring[]Up to 20 change sources that this endpoint should not receive in thecontact.created,contact.updatedandcontact.deletedevents. Each source has up to 64 characters (lowercase letters, numbers,.,_,:or-) and can end with*to work as a prefix, such ascrm:*. It keeps a two-way sync from receiving back the changes it made itself: see the source of changes.
Errors
- 403
plan_limit_reachedThe team already has as many webhooks as the plan allows. - 422
validation_errorA URL that is not HTTPS, an empty list, an unknown or repeated event, or a source in an invalid format.
cURL
curl -X POST https://api.postal.so/webhooks \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "https://acmestore.com/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://acmestore.com/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://acmestore.com/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://acmestore.com/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=="
}
#Get a webhook
GET/webhooks/{id}
Returns the webhook with its current signing_secret.
Path parameters
idstringrequiredWebhook ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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://acmestore.com/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=="
}
#Update a webhook
PATCH/webhooks/{id}
Changes the URL or the events, or pauses the webhook. With disabled, no event is delivered (not even the pending retries) and disabled_reason becomes manual. {"status": "enabled"} reactivates a paused webhook, or one disabled because of failures, and clears disabled_reason and failing_since.
Path parameters
idstringrequiredWebhook ID.
Request body
endpointstringNew HTTPS URL.eventsstring[]New list of events (replaces the previous one).skip_sourcesstring[] | nullstatusstringenabledordisabled.
Errors
- 403
paused_by_planThe webhook is paused because the team went over the plan limit. Delete other webhooks or change plans. - 422
validation_errorA field is invalid. The message says which one. - 404
not_foundThe resource does not exist or belongs to another team.
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"
}
#Delete a webhook
DELETE/webhooks/{id}
Path parameters
idstringrequiredWebhook ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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
}
#Rotate the signing secret
POST/webhooks/{id}/signing-secret/rotate
Generates a new signing_secret, which takes effect immediately. Update your server right after: deliveries signed with the new secret fail verification with the old one.
Path parameters
idstringrequiredWebhook ID.
Errors
- 403
paused_by_planThe webhook is paused by the plan. - 404
not_foundThe resource does not exist or belongs to another team.
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=="
}
#Send a test event
POST/webhooks/{id}/test
Immediately sends a webhook.test event to the endpoint, signed like the others, so you can check the URL and the signature verification. It is a single attempt, with no retries, and works even when the webhook is disabled. It shows up in the events list and does not count toward automatic disabling.
Path parameters
idstringrequiredWebhook ID.
Errors
- 403
paused_by_planThe webhook is paused by the plan. - 404
not_foundThe resource does not exist or belongs to another team.
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"
}
#List a webhook's events
GET/webhooks/{id}/events
The events sent to this webhook in the last 30 days, with their delivery status: pending, attempting (failed and has another attempt at next_attempt_at), success or failed (ran out of attempts, or the webhook was disabled). Accepts limit, after and before.
Path parameters
idstringrequiredWebhook ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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
}
]
}
#Get an event
GET/webhooks/{id}/events/{event_id}
Returns the event with the exact payload that was (or will be) sent.
Path parameters
idstringrequiredWebhook ID.event_idstringrequiredEvent ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Your order #10482 is out for delivery",
"tags": {
"category": "shipping"
}
}
}
}
#List delivery attempts
GET/webhooks/{id}/events/{event_id}/attempts
Each delivery attempt with the HTTP status, the beginning of your server's response (up to 2,000 characters), the connection error, if there was one, and the duration.
Path parameters
idstringrequiredWebhook ID.event_idstringrequiredEvent ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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"
}
]
}
#Replay an event
POST/webhooks/{id}/events/{event_id}/replay
Delivers the event again right away, even if it already succeeded or failed for good. The attempt counter starts over. The delivery uses the same message ID (webhook-id), so your server can recognize the repeat. While the webhook is disabled nothing is delivered: re-enable it first with PATCH /webhooks/{id}.
Path parameters
idstringrequiredWebhook ID.event_idstringrequiredEvent ID.
Errors
- 403
paused_by_planThe webhook is paused by the plan. - 404
not_foundThe resource does not exist or belongs to another team.
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"
}