Skip to content
Pengo Help Center
Browse all guides

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.

Developers Business plan and up 6 min read Updated
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

  1. Go to Settings Integrations Webhooks and click Add webhook.

  2. Enter your Webhook URL.

    It must be a publicly accessible HTTPS URL. Pengo doesn’t deliver to private or internal network addresses.

  3. Under Events to send, pick at least one event.

    You can create several webhooks, each with its own URL and events.

  4. 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.

  5. 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

EventLabel in PengoWhen it’s sent
chat.createdChat createdA visitor starts a new conversation in the chat widget.
message.sentMessage sentA visitor sends a message in the chat widget, or a message is posted through the REST API.
chat.resolvedChat resolvedA conversation is resolved by a teammate in the inbox or from Slack, or automatically after inactivity.
chat.closedChat closedConversations are resolved with a bulk action in the inbox.
chat.ai_resolvedAI resolvedA conversation Pengo Intelligence resolved is confirmed as resolved, after a good rating or once it has stayed resolved.
chat.ratedChat ratedA visitor rates a conversation, in the widget or from the rating email.
chat.assignedChat assignedA conversation is transferred to another teammate, or assigned with a bulk action in the inbox.
chat.status_changedStatus changedA 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:

JSON
{
  "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"
  }
}
FieldDescription
eventThe event name, for example chat.created.
timestampWhen the event happened, in ISO 8601 format. It stays the same on retries.
property_idYour website ID, the propertyId in your install snippet.
dataDetails of the event. The fields depend on the event, see below.

#Fields in data

EventMain fields
chat.createdchat_id, visitor_id, visitor_email, visitor_name, assigned_agent_id, created_at
message.sent from the widgetmessage_id, chat_id, sender_id, sender_type (always visitor), content, has_attachment, created_at
message.sent from the REST APImessage_id, conversation_id, sender_id, sender_type (agent or bot), content, created_at
chat.resolved, chat.closedchat_id, resolved_at, ended_reason
chat.ai_resolvedchat_id, confirmed_at
chat.ratedchat_id, rating_id, rating, rating_type, feedback, sentiment, visitor_id, assigned_to, rated_at
chat.assigned after a transferchat_id, from_agent_id, to_agent_id, department, notes, transfer_history, transferred_at
chat.assigned, chat.status_changed from a bulk actionchat_id, action, value, status, assigned_to, updated_at
chat.status_changed when a visitor reopens a chatchat_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

HeaderValue
Content-Typeapplication/json
User-AgentPengo-Webhook/1.0
X-Pengo-EventThe event name.
X-Pengo-TimestampThe same value as timestamp in the body.
X-Pengo-Delivery-AttemptThe attempt number, starting at 1.
X-Pengo-Signature-V2t= a Unix time in seconds, then ,v1= and the signature. Use this one.
X-Pengo-Signaturesha256= 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:

  1. Take t and v1 from the header.
  2. 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.
  3. Compare it with v1 using a constant-time comparison.
  4. Reject the request if t is 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:

JavaScript
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 2xx status counts as delivered.
  • Timeouts, network errors and the statuses 408, 425, 429 and 5xx are retried. The first retry comes after about 30 seconds and the delay doubles each time, up to 8 attempts over roughly an hour.
  • Other 4xx statuses, such as 400, 401 or 404, 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:

JSON
{
  "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.