> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs-parschat.parstechai.com/documentation/guides/webhook-events/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs-parschat.parstechai.com/_mcp/server. # Webhook events > Delivery, signature verification, your own endpoint auth, retries, and every payload we send. > **Warning** > > **Pre-release `v1`, published 2026-09-14.** This page commits to the shape of the API, not to a > date. We will build exactly what is documented here. The shape can still change until > **2026-10-14**; after that, changes follow [Versioning and stability](/documentation/reference/versioning-stability). > > The banner comes off one page at a time as each route goes live. While it is here, build against > the contract and assume the route is not callable yet. ## Delivery ```http POST Content-Type: application/json X-ParsChat-Signature: X-ParsChat-Event: message X-ParsChat-Delivery: dlv_9fK2mQ ``` Reply `2xx` as soon as the event is stored, then do your processing afterwards. A slow handler becomes a timeout, and a timeout becomes a retry. ## Verify the signature **`Python`** ```python Python import hashlib import hmac def is_authentic(raw_body: bytes, header_signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header_signature) ``` **`Node.js`** ```javascript Node.js const crypto = require("crypto"); function isAuthentic(rawBody, headerSignature, secret) { const expected = crypto .createHmac("sha256", secret) .update(rawBody) // a Buffer, not a parsed object .digest("hex"); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(headerSignature ?? "", "utf8"); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: capture the raw body BEFORE express.json() parses it // app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } })); ``` **`PHP`** ```php PHP function is_authentic(string $rawBody, string $headerSignature, string $secret): bool { $expected = hash_hmac('sha256', $rawBody, $secret); return hash_equals($expected, $headerSignature); } // $rawBody = file_get_contents('php://input'); ``` > **Warning** > > **Verify against the raw bytes, before JSON parsing.** Re-serialising changes key order and > whitespace and the signature will never match. Use `compare_digest`, not `==`. ## If your endpoint needs its own authentication Some partners cannot accept an unauthenticated `POST`. A bank's gateway or WAF may require a bearer token or an API key before the request reaches the application at all. Our signature proves the payload came from us, but it does not satisfy your infrastructure's rule. You can give us a header set, and we attach it to every delivery, verbatim: ```json { "Authorization": "Bearer ", "X-Your-Api-Key": "…", "X-Client-Id": "…" } ``` A plain bearer token works. It is just an `Authorization` header like any other. | | | | -------- | -------------------------------------------------------------------------------- | | Set by | **You**, in the ParsChat panel, under optional advanced settings | | Stored | Encrypted at rest, never returned by any read endpoint | | Rotation | Change it in the panel yourself; deliveries in flight use the value at send time | The webhook URL and this token both live in your panel. Only the **return URL** comes to us, because it decides where a real customer's browser is sent mid-signup. > **Warning** > > **The signature is still what proves authenticity.** Your headers get the request past your own > gate; `X-ParsChat-Signature` tells you the body is genuinely ours. **Verify the signature even > when your own auth passed.** A caller who learned your token could otherwise post you fabricated > events. > **Info** > > **Not covered: mTLS.** If your infrastructure mandates client certificates, tell us before you > integrate. It requires a build on our side rather than a configuration change. ### A complete receiver ```python import hashlib import hmac import os from fastapi import FastAPI, Header, Request, Response app = FastAPI() SECRET = os.environ["PARSCHAT_WEBHOOK_SECRET"] @app.post("/parschat/events") async def receive( request: Request, x_parschat_signature: str = Header(default=""), x_parschat_event: str = Header(default=""), x_parschat_delivery: str = Header(default=""), ) -> Response: raw = await request.body() expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, x_parschat_signature): return Response(status_code=401) # Store first, process later. A slow handler becomes a timeout, # and a timeout becomes a retry of an event you already have. enqueue(x_parschat_delivery, x_parschat_event, raw) return Response(status_code=200) ``` > **Info** > > **`X-ParsChat-Delivery` is stable across retries of the same event.** Store it and ignore a > delivery id you have already processed. That keeps you correct when a `2xx` of ours is lost in > transit and we retry an event you did handle. ## Send yourself a test event Before you wait for real traffic, make us deliver to you on demand: ```bash curl -X POST https://api-chat.parstechai.com/v1/webhooks/test \ -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxx" ``` ```json { "delivery_id": "dlv_4mN6pL", "status": "pending" } ``` **It is a real delivery**, not a simulation: signed with your secret, carrying your custom headers, and retried on the same schedule as any other event. So it exercises your signature verification and your firewall rules, not merely whether your host resolves. Its `data` carries `"test": true` so your handler can discard it instead of creating a conversation from it. ```python if event["data"].get("test"): return 200 ``` The call returns `202` as soon as the event is queued — that is not proof your endpoint accepted it. Look the `delivery_id` up in the delivery log below to see what you actually answered. > **Note** > > A `422` means no webhook URL is configured for your account yet. Set one in your ParsChat panel > first. ## Retries Anything other than a `2xx`, including a timeout, is retried with backoff: **6 attempts over about an hour.** | Attempt | After | | ------- | ----------- | | 1 | immediately | | 2 | 15 s | | 3 | 1 m | | 4 | 5 m | | 5 | 15 m | | 6 | 1 h | Then the delivery is **dead-lettered** and visible in the panel, so you can see what failed instead of discovering a silent gap. > **Warning** > > **Do not rely on retries as your only safety net.** After the last attempt the event is not > redelivered automatically. Use the [usage API](/documentation/guides/usage-reports) to reconcile a gap. ## The delivery log When an event you expected never arrived, this answers *did you actually send it?* ```bash curl "https://api-chat.parstechai.com/v1/webhooks/deliveries?status=failed" \ -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxx" ``` ```json { "data": [ { "delivery_id": "dlv_7bX4tK", "event": "chat", "status": "failed", "attempt_count": 3, "last_status_code": 502, "last_error": "upstream returned 502", "next_attempt_at": "2026-09-20T11:24:00Z", "delivered_at": null, "created_at": "2026-09-20T11:08:11Z" } ], "pagination": { "limit": 50, "offset": 0, "total_count": 1, "has_more": false } } ``` `delivery_id` is the same value that arrives in the event body, so you can match a log entry to something you did or did not receive. | `status` | What it means | | --------------- | ------------------------------------------------------------- | | `pending` | Queued, or waiting for its next retry | | `delivered` | Your endpoint returned `2xx` | | `failed` | An attempt failed. It will be retried — see `next_attempt_at` | | `dead_lettered` | All 6 attempts used. **We will not try again** | `last_status_code` and `last_error` are the useful pair when debugging: a `502` with `next_attempt_at` set is transient and self-correcting, while six `500`s ending in `dead_lettered` is a gap you need to reconcile yourself. > **Note** > > **Message bodies are not included.** This is a delivery log, not a second way to read your > conversations — those are governed by the [conversations](/documentation/guides/replying-to-customers) > and [usage](/documentation/guides/usage-reports) endpoints, which apply their own rules. ## Check your configuration ```bash curl https://api-chat.parstechai.com/v1/webhooks \ -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxx" ``` ```json { "url": "https://partner.example/parschat/events", "secret_set": true, "events": ["message", "chat", "reaction", "is_typing"], "updated_at": "2026-09-12T08:15:00Z" } ``` Read-only, so you can verify your configuration from code without being able to change it. The secret is **never returned**, only whether one is set. ## The envelope Every event has the same outer shape: ```json { "schema_version": "1.0.0", "event": "message", "delivery_id": "dlv_9fK2mQ", "occurred_at": "2026-09-20T11:09:02Z", "robot_id": "rbt_8fK2mQ", "external_id": "shop_10422", "channel_id": "chn_3pQ7xL", "data": { } } ``` `schema_version` is semver. **Fields are added without a major bump**, so ignore ones you do not recognise. Nothing is removed or repurposed without a new major version and notice. ## Event types | Event | Fires when | | -------------------- | ------------------------------------------------------------- | | `message` | A message is sent or received, **including attachments** | | `message.generating` | The AI has started composing a reply. Show a typing indicator | | `chat` | A conversation changes state | | `reaction` | A reaction is added or removed | | `is_typing` | A **human** on the other side starts or stops typing | > **Info** > > `message.generating` and `is_typing` are two different facts. The first is the **AI** composing, > the second is a **human** operator typing. You may want to render them differently. Full payloads for every event: [Webhook event reference](/documentation/reference/webhook-event-reference). ## Message types Attachments arrive as ordinary `message` events with a non-text `type`, never as a separate event: `text` · `voice` · `image` · `video` · `multimedia` · `document` · `poll` · `form` · `template` ## Four fields worth explaining #### external\_id: your idempotency key If you send a message with an `external_id` you have used before, we do not create a duplicate. Use it to make retries safe. #### reply\_to\_id: threaded replies The ParsChat `message_id` this message replies to, so you can render the quoted message. `null` when the message is not a reply. #### markdown\_content: rich formatting Set when the content has formatting. `text` stays the plain rendering, so you can use both: markdown in a rich chat view, plain text in a notification or SMS fallback. Either may be `null`, so never assume both are present. #### form\_data: structured form answers A list of `{type, situation, key, value}`, present when `type` is `form`. ```json { "type": "form", "text": null, "form_data": [ { "type": "text", "situation": "question", "key": "Your name", "value": "Sara" }, { "type": "single_select", "situation": "rate", "key": "Service rating", "value": "excellent" } ] } ``` Forms are not Instagram-only. A customer connecting through the widget produces the same shape. > Delivery, signature verification, your own endpoint auth, retries, and every payload we send.