API Reference
Webhooks
💬Get free consultation

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.

MethodPathPurpose
POST/chat/webhooksCreate a subscription. Upserts by URL.
GET/chat/webhooksList 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": {}
      }'
FieldRequiredDescription
urlYesWhere Chatty sends events. Must be https://.
eventsYesArray of event names from the table below.
secretYesYour signing secret. Chatty uses it to sign every delivery.
metadataNoAny 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

EventFires whendata contains
message.createdA customer message arrives, or an agent or the AI replies.messageId, text, senderType, memberId, createdAt
message.updatedA message is edited.Same as above
message.removedA message is deleted.Same as above
conversation.createdA new conversation starts.type
conversation.state_changedA conversation is resolved or reopened.status, previousStatus
conversation.assignedA conversation is assigned or reassigned.memberId, previousMemberId
conversation.tags_updatedTags change.tags (the full resulting list)
conversation.removedA 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:

HeaderDescription
X-Webhook-EventThe event name.
X-Webhook-TimestampUnix timestamp in seconds, as a string.
X-Webhook-SignatureHMAC-SHA256 hex digest, signed with your subscription secret.
X-Webhook-Event-IdUnique 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 2xx quickly 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.