Webhooks
Pong posts new messages to your URL as they happen — one JSON shape for every channel, signed with your webhook secret.
Webhooks are part of the Premium plan.
Set up
- Desk → Settings → Webhooks: enter an HTTPS URL.
- Choose which message types to send: visitor (default), agent, AI, system, and contact form bot (
bot, off by default). - Copy the signing secret and press Send test event.
Payload
POST /your/endpoint
content-type: application/json
X-Pong-Signature: t=1762862400,v1=5f2b9c...
{
"version": 1,
"eventName": "chatFragment",
"eventId": "e4b1c8a2f5d97b3c6e0a1f2d8b4c7e95",
"createdAt": "2026-11-11T12:00:00.000Z",
"client": {
"id": "3f9c2a1e7b8d4c60a5e2b7f1c9d03e4a",
"chatId": "b71e0c94d2a35f68e1c7a09b4d2f6e13",
"verified": true,
"name": "Jane Doe",
"email": "jane@acme.com",
"phone": "+372 5555 1234",
"fields": { "company": "Acme" },
"notes": null,
"groupId": null,
"banned": false,
"integrationData": { "plan": "Pro" },
"geo": { "country": "DE", "city": "Berlin", "lat": 52.52, "lon": 13.4 },
"avatar": "https://files.pong.ee/...",
"referrer": "https://www.google.com/",
"landing": "https://acme.com/pricing"
},
"messages": [
{
"id": "8a4d1f2e9c0b7a6354e1d2c3b4a59687",
"type": "visitor",
"channel": "web",
"text": "Hi! Can I change my plan mid-month?",
"attachments": [],
"agentId": null,
"createdAt": "2026-11-11T12:00:00.000Z"
}
]
}
| eventName | When |
|---|---|
chatStarted | The first message of a conversation |
chatFragment | Every later message |
test | The test button in settings (client is null) |
messagesis always an array.client.idis always the primary id, after any merges.- Times are ISO 8601 strings here (REST uses Unix milliseconds).
channelis the only channel-specific field:weboremail. An email to your support inbox arrives as avisitormessage withchannel: "email", itstextis the body without the quoted history, starting with the subject in bold when the email starts a new thread. The first email in a person’s email conversation fireschatStarted.- Messages sent by triggers are
agentmessages, signed with the agent chosen in the trigger. client.phoneandclient.fieldscarry what the visitor entered in the contact form;fieldsis keyed by the keys you set for your custom fields in Settings → Contact form.
Verify the signature
The header is t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(webhookSecret, t + "." + rawBody). Use the raw request body, before JSON parsing, and reject requests older than 5 minutes.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyPong(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
PHP
<?php
$raw = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_PONG_SIGNATURE'] ?? ''), $sig);
$ok = abs(time() - (int)($sig['t'] ?? 0)) <= 300
&& hash_equals(hash_hmac('sha256', $sig['t'] . '.' . $raw, PONG_WEBHOOK_SECRET), $sig['v1'] ?? '');
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw);
Python
import hashlib, hmac, time
def verify_pong(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in header.split(','))
if abs(time.time() - int(parts['t'])) > 300:
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts['v1'])
Delivery and retries
- Answer with any
2xxwithin 10 seconds. Anything else is retried with growing pauses (30 s, 1 min, 2 min … up to 1 h), up to 10 attempts. - Delivery is at least once: the same event can arrive twice. Deduplicate by
eventIdormessages[].id. - Order is not guaranteed; sort by
messages[].createdAtif it matters.