Webhooks
Send Pengo chat events to your own server, Zapier, Make or n8n as they happen, and verify every request with an HMAC-SHA256 signature.
On this page
Webhooks send an HTTP POST to a URL you choose whenever something happens in Pengo, such as a new conversation, a visitor message or a rating. Use them to sync conversations into your own systems, alert another tool, or start an automation in Zapier, Make or n8n.
#Create a webhook
-
Go to Settings Integrations Webhooks and click Add webhook.
-
Enter your Webhook URL.
It must be a publicly accessible HTTPS URL. Pengo doesn’t deliver to private or internal network addresses.
-
Under Events to send, pick at least one event.
You can create several webhooks, each with its own URL and events.
-
Click Create webhook, then Save changes.
The webhook isn’t stored until you save. Pengo creates a signing secret for it that starts with
whsec_. The webhook list shows the start of it. -
Click Test on the webhook to send a test ping.
Pengo shows whether your server answered with a success status. See test your endpoint.
To remove a webhook, click the delete icon on it, confirm, and save. Deliveries still waiting to be sent to a deleted webhook are dropped.
#Events
| Event | Label in Pengo | When it’s sent |
|---|---|---|
chat.created | Chat created | A visitor starts a new conversation in the chat widget. |
message.sent | Message sent | A visitor sends a message in the chat widget, or a message is posted through the REST API. |
chat.resolved | Chat resolved | A conversation is resolved by a teammate in the inbox or from Slack, or automatically after inactivity. |
chat.closed | Chat closed | Conversations are resolved with a bulk action in the inbox. |
chat.ai_resolved | AI resolved | A conversation Pengo Intelligence resolved is confirmed as resolved, after a good rating or once it has stayed resolved. |
chat.rated | Chat rated | A visitor rates a conversation, in the widget or from the rating email. |
chat.assigned | Chat assigned | A conversation is transferred to another teammate, or assigned with a bulk action in the inbox. |
chat.status_changed | Status changed | A visitor’s reply reopens a resolved conversation, or teammates reopen conversations or change their status with a bulk action. |
Replies your team sends from the inbox and replies from Pengo Intelligence don’t trigger message.sent.
#What Pengo sends
Each delivery is a POST with a JSON body. Every event uses the same envelope:
{
"event": "chat.created",
"timestamp": "2026-10-19T09:30:12.345Z",
"property_id": "7c0e2f4a-1b2c-4d5e-8f90-123456789abc",
"data": {
"chat_id": "3f6d1c2e-9a8b-4c7d-a6e5-0f1e2d3c4b5a",
"visitor_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"visitor_email": "ada@example.com",
"visitor_name": "Ada",
"assigned_agent_id": null,
"created_at": "2026-10-19T09:30:12.101Z"
}
} | Field | Description |
|---|---|
event | The event name, for example chat.created. |
timestamp | When the event happened, in ISO 8601 format. It stays the same on retries. |
property_id | Your website ID, the propertyId in your install snippet. |
data | Details of the event. The fields depend on the event, see below. |
#Fields in data
| Event | Main fields |
|---|---|
chat.created | chat_id, visitor_id, visitor_email, visitor_name, assigned_agent_id, created_at |
message.sent from the widget | message_id, chat_id, sender_id, sender_type (always visitor), content, has_attachment, created_at |
message.sent from the REST API | message_id, conversation_id, sender_id, sender_type (agent or bot), content, created_at |
chat.resolved, chat.closed | chat_id, resolved_at, ended_reason |
chat.ai_resolved | chat_id, confirmed_at |
chat.rated | chat_id, rating_id, rating, rating_type, feedback, sentiment, visitor_id, assigned_to, rated_at |
chat.assigned after a transfer | chat_id, from_agent_id, to_agent_id, department, notes, transfer_history, transferred_at |
chat.assigned, chat.status_changed from a bulk action | chat_id, action, value, status, assigned_to, updated_at |
chat.status_changed when a visitor reopens a chat | chat_id, previous_status, status, reopened_at |
For chat.resolved and chat.closed, ended_reason says how the conversation ended, for example agent_resolved, auto_resolved_inactivity or bulk_resolved.
Note that message.sent from the REST API names the conversation conversation_id, while every other event uses chat_id. Treat any field as optional and ignore fields you don’t recognise, so your code keeps working as Pengo adds detail.
#Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Pengo-Webhook/1.0 |
X-Pengo-Event | The event name. |
X-Pengo-Timestamp | The same value as timestamp in the body. |
X-Pengo-Delivery-Attempt | The attempt number, starting at 1. |
X-Pengo-Signature-V2 | t= a Unix time in seconds, then ,v1= and the signature. Use this one. |
X-Pengo-Signature | sha256= and a signature of the body alone. Kept for older integrations. |
#Verify the signature
Every delivery is signed with your webhook’s signing secret, so you can check that it really came from Pengo and wasn’t changed or replayed.
The X-Pengo-Signature-V2 header looks like t=1792402212,v1=5d41…. To check it:
- Take
tandv1from the header. - Compute an HMAC-SHA256, keyed with your signing secret, of the text
t, then a full stop, then the raw request body exactly as received. Encode it as hex. - Compare it with
v1using a constant-time comparison. - Reject the request if
tis more than 5 minutes away from your server’s current time.
Always use the raw body. If your framework parses the JSON first and you sign it again, the bytes may differ and the check fails.
A complete example with Node.js and Express:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.PENGO_WEBHOOK_SECRET; // your webhook's signing secret
function verifyPengoSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header || !secret) return false;
const parts = {};
for (const part of header.split(',')) {
const i = part.indexOf('=');
parts[part.slice(0, i).trim()] = part.slice(i + 1);
}
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(parts.v1);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as bytes, exactly as Pengo sent it.
app.post('/webhooks/pengo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyPengoSignature(rawBody, req.get('X-Pengo-Signature-V2'), SECRET)) {
return res.status(401).send('Invalid signature');
}
const { event, data } = JSON.parse(rawBody);
if (event === 'chat.created') {
// …
}
res.sendStatus(200);
});
app.listen(3000); The older X-Pengo-Signature header is sha256= followed by the hex HMAC-SHA256 of the raw body alone. It has no timestamp, so it doesn’t protect against replays. Use it only if your integration already relies on it.
#Retries and timeouts
- Pengo queues each delivery and sends it shortly after the event.
- Your endpoint has 6 seconds to respond. Any
2xxstatus counts as delivered. - Timeouts, network errors and the statuses
408,425,429and5xxare retried. The first retry comes after about 30 seconds and the delay doubles each time, up to 8 attempts over roughly an hour. - Other
4xxstatuses, such as400,401or404, are treated as final and not retried. - Pengo follows up to 3 redirects.
- Each attempt uses the webhook’s current settings. If you delete the webhook, deliveries still waiting are dropped.
Because of retries, the same event can arrive more than once, for example when your server handled it but answered too slowly. Make your handler safe to run twice. The combination of event, timestamp and the ID in data (such as message_id or chat_id) identifies a delivery.
Respond quickly with a 2xx and do slow work, such as calling other APIs, in the background.
#Test your endpoint
Click Test on a webhook to send a test ping. It’s signed with the same secret and headers as real deliveries, with X-Pengo-Event: ping and this body:
{
"event": "ping",
"timestamp": "2026-10-19T09:30:12.345Z",
"data": { "message": "This is a test webhook from Pengo" }
} Pengo shows Delivered with the status code when your server answers with a 2xx, or the status code or error it got instead. Test pings aren’t retried.
To see real events, start a conversation on your site in a private browser window and send a message. Tools such as a request inspector or your server logs help you check the exact payload while you build.
#Troubleshooting
- That URL points to a private or blocked address. Pengo only delivers to public addresses. Use a public URL or a tunnelling tool while you develop locally.
- Request timed out. Your endpoint took longer than 6 seconds. Respond first, then process the event.
- Server returned HTTP 401 or 403. Your endpoint rejected the request. Check your signature code and any authentication in front of it.
- Signature never matches. Make sure you sign the raw body, not re-serialised JSON, and that you use the secret of the webhook that sent the request.
Common questions
Can I send events to Zapier, Make or n8n?
Yes. Create a webhook in Pengo and paste the catch or webhook URL that Zapier, Make or n8n gives you as the Webhook URL.
Do replies from my team trigger message.sent?
No. message.sent fires for messages visitors send in the chat widget and for messages posted through the REST API.
What happens if my server is down?
Pengo retries failed deliveries with increasing delays, up to 8 attempts over roughly an hour. Errors such as 400 or 404 are not retried.
Was this guide helpful?
Your answer helps us improve it.
Sorry about that. or contact support, and we'll fix the guide.