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"],
"secret": "a-long-random-signing-secret",
"metadata": {}
}'| Field | Required | Description |
|---|---|---|
url | Yes | Where Chatty sends events. Must be https://. |
events | Yes | Array of event names from the table below. |
secret | Yes | Your signing secret. Chatty uses it to sign every delivery. |
metadata | No | Any object you want stored alongside the subscription. 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}.
A re-POST of the same url also switches isActive back to true. 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, a missing secret, 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 and rotate every subscription 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 |
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 |
A subscription can listen to one or more events.
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"
}
}Legacy events
Two older event names still work. They keep their original payload shape, which does not match the envelope above, and the two shapes differ from each other.
customer_message fires when a customer sends a new message.
{
"timestamp": "2026-06-11T10:13:52.305Z",
"shopId": "your-shop-id",
"shopifyDomain": "your-store.myshopify.com",
"convoId": "conversation-id",
"message": {
"text": "Hi, do you have the Daris Tee in size M?",
"createdAt": "2026-06-11T10:13:52.305Z",
"customerEmail": "shopper@example.com",
"customerShopifyId": "gid://shopify/Customer/123"
},
"context": {
"detectedLanguage": "en",
"country": "US",
"locale": "en"
}
}ai_response fires when the AI assistant responds to a visitor. It adds query and a richer message, and carries no context.
{
"timestamp": "2026-06-11T10:13:54.120Z",
"shopId": "your-shop-id",
"shopifyDomain": "your-store.myshopify.com",
"convoId": "conversation-id",
"query": "do you have the Daris Tee in size M?",
"message": {
"text": "Yes! The Daris Tee is in stock in size M.",
"topic": "product-availability",
"isAskBotResponse": "found",
"chatbotProducts": [],
"chatbotFaqs": []
}
}For ai_response, message.isAskBotResponse is "found" when the AI had an answer and "not-found" when it didn't. That's a useful signal for routing unanswered questions to a human. The chatbot* arrays list the sources the AI used.
Don't subscribe to both customer_message and message.created. One customer message fires both, so you receive two deliveries carrying different X-Webhook-Event-Id values. De-duplication won't collapse them. Pick one family per subscription.
Building something new? Use message.created and the conversation.* events. They carry an eventId for de-duplication and a stable envelope. The legacy events are kept so existing integrations keep working.
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 your subscription secret. |
X-Webhook-Event-Id | Unique id for this event. The same id repeats across retries. |
The signature covers the timestamp and the body together, not the body alone. Sign the exact string `${timestamp};${body}` — 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 3 attempts. Any non-2xx response or timeout is retried, with a short pause that grows between attempts.
- 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. - No delivery after the third failure. The failure is recorded on our side, but the event is not queued for later. Return
2xxquickly and do the heavy work in the background. - Independent delivery. Each subscription is delivered separately. One failing endpoint doesn't affect the others.
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.
What happens if my server is down?
Chatty tries 3 times, then stops. There is no retry queue beyond that, so the event is lost. Keep your endpoint highly available, return 2xx immediately, and process asynchronously.
Why am I receiving the same event twice?
A retry. If your endpoint takes longer than 10 seconds or returns a non-2xx response, Chatty sends the event again with the same X-Webhook-Event-Id. 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?
Yes. http://localhost and http://127.0.0.1 are accepted for local development. Every other URL must use https://.
I set up webhooks with the older /webhook/subscriptions endpoints. Do they still work?
Yes, those endpoints still run, authenticated with X-Webhook-App-Id and X-Webhook-Secret. New integrations should use /chat/webhooks instead: it authenticates with your API key, so you can create your first subscription without already having one. 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