Webhooks
Who can use this feature?
- You need the Pro or Plus plan to create an API key. See the API reference.
Overview
Webhooks let Chatty notify your server the moment something happens in a conversation. Instead of polling, you register a URL once and Chatty POSTs a JSON payload to it whenever a matching event fires.
Common uses:
- Mirror conversations into your own helpdesk or database
- Alert your team when a customer asks something the AI couldn't answer
- Log AI replies for quality review or analytics
Manage subscriptions
Subscriptions are managed with the same X-Api-Key that authenticates the rest of the Chat Conversations API.
| Method | Path | Purpose |
|---|---|---|
POST | /chat/webhooks | Create a subscription. Upserts by URL. |
GET | /chat/webhooks | List your subscriptions. |
PUT | /chat/webhooks/{id} | Update a subscription. |
DELETE | /chat/webhooks/{id} | Delete a subscription. |
Create a subscription
curl -s -X POST "https://app.chatty.net/chat/webhooks" \
-H "X-Api-Key: $CHATTY_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/chatty-webhook",
"events": ["message.created", "conversation.state_changed"],
"metadata": {}
}'| Field | Required | Description |
|---|---|---|
url | Yes | Where Chatty sends events. Must be https://. |
events | Yes | Array of event names from the table below. |
metadata | No | Any object you want stored alongside the subscription, up to 2,048 bytes serialized. It is returned by GET /chat/webhooks, but it is not included in delivered events. |
Returns 201 with the created subscription. Registering a url you already use updates that subscription instead of creating a duplicate. To pause delivery without deleting, PUT the subscription with {"isActive": false}.
You do not choose the signing secret. Chatty generates it. The 201 response carries a secret that starts with whsec_, and that value is the only thing that verifies your deliveries. Store it on your server the moment you read the response. A secret you send in the request body is ignored.
A re-POST of the same url also switches isActive back to true and keeps the existing signing secret, so re-creating a subscription never breaks verification. If you paused a subscription, don't re-create it to change its events. PUT it instead, or it starts delivering again.
A bad url or an unknown event name returns 400 INVALID_PARAMS with the reason in error.message.
PUT and DELETE also answer 400 INVALID_PARAMS with Subscription not found when the id is unknown or belongs to another store. They do not return 404.
GET /chat/webhooks returns each subscription's secret in plaintext. Anyone holding your API key can read your signing secrets, so store and scope the key accordingly. If a key leaks, delete it, then replace every signing secret. There is no rotate button, so delete each subscription and create it again to get a fresh secret.
Events
| Event | Fires when | data contains |
|---|---|---|
message.created | A customer message arrives, or an agent or the AI replies. | messageId, text, senderType, memberId, createdAt |
message.updated | A message is edited. | Same as above |
message.removed | A message is deleted. | Same as above |
conversation.created | A new conversation starts. | type, customerId, channel, isChatBot, isFirstMessage |
conversation.state_changed | A conversation is resolved or reopened. | status, previousStatus |
conversation.assigned | A conversation is assigned or reassigned. | memberId, previousMemberId |
conversation.tags_updated | Tags change. | tags (the full resulting list) |
conversation.removed | A conversation is deleted. | Empty |
customer.created | A new customer record is created in your store. | customerId, email, firstName, lastName, phone, tags, ordersCount, totalSpent |
These nine names are the complete list. Any other name returns 400 INVALID_PARAMS. A subscription can listen to one or more events.
customer.created is the one event that belongs to no conversation, so its conversationId is null.
senderType is one of customer, agent, bot, or system. An AI reply is bot, a human on your team is agent.
message.created does not fire for internal notes or activity lines. Notes never leave your inbox, and activity is reported through the conversation.* events instead.
Payload envelope
Every event uses the same envelope. Only data changes.
{
"eventId": "b3f1c9a0-4c2e-4f1a-9b77-2d5e8a0c1f34",
"type": "message.created",
"shopId": "your_shop_id",
"conversationId": "conversation_id",
"occurredAt": "2026-08-03T12:00:00.000Z",
"data": {
"messageId": "msg_123",
"text": "What is your return policy?",
"senderType": "customer",
"memberId": null,
"createdAt": "2026-08-03T12:00:00.000Z"
}
}Every event carries an eventId you can de-duplicate on, and data is the only part that changes between event types.
conversationId can be passed straight to GET /chat/conversations/{id} to pull the full record. It is null on customer.created, which has no conversation, and it is not guaranteed to be the same id form on every event, so key your own storage on the firestoreId and pgConvoId that read returns, not on the raw string in the envelope.
The older event names were removed in August 2026. ai_response, customer_message, and every underscore-style name are gone. If you built against a name that is not in the table above, that subscription now returns 400 INVALID_PARAMS. Point it at message.created and read data.senderType to tell a customer message from an AI reply.
Verify the signature
Every delivery carries these headers:
| Header | Description |
|---|---|
X-Webhook-Event | The event name. |
X-Webhook-Timestamp | Unix timestamp in seconds, as a string. |
X-Webhook-Signature | HMAC-SHA256 hex digest, signed with the whsec_ secret returned when you created the subscription. |
X-Webhook-Event-Id | Unique id for this event. The same id repeats across retries, and across every subscription the event reaches. |
X-Webhook-Delivery-Id | Unique id for this subscription's delivery of the event. Also stable across retries. |
The signature covers the timestamp and the body together, not the body alone. Sign the exact string `${timestamp};${body}`, meaning the timestamp, a semicolon, then the raw request body. Binding the timestamp in is what stops a captured request from being replayed later.
const crypto = require('crypto')
function verifyWebhook(rawBody, timestamp, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp};${rawBody}`)
.digest('hex')
const a = Buffer.from(signature)
const b = Buffer.from(expected)
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}Also check that X-Webhook-Timestamp is recent, a few minutes at most, and reject anything older. The signature alone does not expire.
Always verify against the raw, unparsed request body. If your framework parses JSON before you can read the raw bytes, the re-serialized body may not match the signature.
Delivery behavior
- Respond within 10 seconds. Chatty aborts the request after that and counts it as a failure.
- Up to 7 attempts over about 9 hours. A timeout, a network error, or a
5xxschedules another try at roughly 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours. See Errors and rate limits. - De-duplicate on
X-Webhook-Event-Id. Every retry carries the same id. If your endpoint is slow but eventually succeeds, you can receive the same event more than once. - The subscription is disabled after the seventh failure. Delivery stops for every event, not only the one that failed, until you
PUTthe subscription with{"isActive": true}. Turning it back on needs the Pro or Plus plan, while pausing it does not. Return2xxquickly and do the heavy work in the background. 410 Gonedisables the subscription immediately, with no retries. Return it only when the endpoint is permanently retired.- A redirect is never followed. A
3xxis treated the same as a4xx: the delivery is dropped and never retried, because a redirect target is not re-checked for safety. Register the final URL. - A payload above 80,000 bytes arrives without its
data. The envelope still comes through, anddatais replaced with{"truncated": true, "originalSize": <bytes>}. Read the record back over the Chat Conversations API when you see that flag. - Independent delivery. Each subscription is delivered separately. One failing endpoint doesn't affect the others.
- Order is not guaranteed. Every event is queued and retried on its own, so a retried event can land after one that happened later. Two messages a second apart can reach you in either order. Sort by
occurredAton the envelope, and treat an event as out of date when you already hold something newer for that conversation. - Delivery is a notification, not a ledger. Chatty never fails a chat action because a webhook could not be queued, so a rare event may never reach you at all. If your copy has to be complete, reconcile on a schedule with the Chat Conversations API rather than rebuilding from the event stream alone.
FAQs
Can I have more than one webhook URL?
Yes. Create a separate subscription for each URL. Each one can listen to a different set of events and uses its own signing secret.
How do I temporarily stop receiving events?
PUT /chat/webhooks/{id} with {"isActive": false}. Set it back to true to resume. You don't need to recreate the subscription. Pausing works on any plan, but resuming needs the Pro or Plus plan, so a store that downgraded while paused cannot turn its subscriptions back on.
What happens if my server is down?
Chatty retries the delivery up to 7 times across about 9 hours, so a short outage usually recovers on its own. If every attempt fails, the subscription is disabled and stops receiving events until you re-enable it with PUT /chat/webhooks/{id} and {"isActive": true}. Check isActive on your subscriptions after any long outage.
Why am I receiving the same event twice?
A retry. If your endpoint takes longer than 10 seconds, is unreachable, or returns a 5xx, Chatty sends the event again with the same X-Webhook-Event-Id. A 4xx is treated as your decision and is never retried. Store the ids you have processed and skip repeats.
My bot replies through the API and keeps triggering itself. How do I stop the loop?
Every reply you send through POST /chat/conversations/{id}/messages fires its own message.created event. Check data.senderType on each event and act only on customer. Ignore agent, bot, and system.
If I delete my API key, do my subscriptions stop?
No. Subscriptions belong to your store, not to a key. Deleting a key blocks new API calls, but Chatty keeps delivering events to every URL you registered. Delete the subscription itself to stop delivery.
Can I test against a local server?
Not directly. A registered URL must use https:// and its hostname must resolve to a public address. Chatty re-checks that on every delivery and refuses private ranges, so a URL that points at localhost or an internal IP is rejected even if it was accepted once. Run a tunnel, such as ngrok or Cloudflare Tunnel, and register the public HTTPS address it gives you.
I set up webhooks with the older /webhook/subscriptions endpoints. Do they still work?
Yes, those endpoints still run, but they authenticate differently per method. POST /webhook/subscriptions takes X-Webhook-App-Id (your App ID) and X-Webhook-Secret (your sk_ key), and it is the one place that checks your plan: it requires the Pro or Plus plan. GET, PUT, and DELETE take the whsec_ secret of the subscription itself, so you cannot list your subscriptions without already holding one.
New integrations should use /chat/webhooks instead: it authenticates with your API key on every method, and it checks your plan in one place only: turning a paused subscription back on. Creating, listing, updating other fields, and deleting do not. The signature format and delivery behavior are the same on both.
Need help?
If your endpoint isn't receiving events, check that the url is publicly reachable over HTTPS and returns 2xx within 10 seconds. If deliveries arrive but fail verification, check that you are signing `${timestamp};${body}` and not the body alone. Still stuck? Contact the Chatty support team from your dashboard.
Chatty Help Center