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"],
        "metadata": {}
      }'
FieldRequiredDescription
urlYesWhere Chatty sends events. Must be https://.
eventsYesArray of event names from the table below.
metadataNoAny 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

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, customerId, channel, isChatBot, isFirstMessage
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
customer.createdA 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:

HeaderDescription
X-Webhook-EventThe event name.
X-Webhook-TimestampUnix timestamp in seconds, as a string.
X-Webhook-SignatureHMAC-SHA256 hex digest, signed with the whsec_ secret returned when you created the subscription.
X-Webhook-Event-IdUnique id for this event. The same id repeats across retries, and across every subscription the event reaches.
X-Webhook-Delivery-IdUnique 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 5xx schedules 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 PUT the subscription with {"isActive": true}. Turning it back on needs the Pro or Plus plan, while pausing it does not. Return 2xx quickly and do the heavy work in the background.
  • 410 Gone disables the subscription immediately, with no retries. Return it only when the endpoint is permanently retired.
  • A redirect is never followed. A 3xx is treated the same as a 4xx: 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, and data is 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 occurredAt on 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.