Webhook events

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.

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

POST <your url>
Content-Type: application/json
X-ParsChat-Signature: <hex hmac-sha256 of raw body, keyed with your secret>
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

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)

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:

{
"Authorization": "Bearer <a token you issue to us>",
"X-Your-Api-Key": "",
"X-Client-Id": ""
}

A plain bearer token works. It is just an Authorization header like any other.

Set byYou, in the ParsChat panel, under optional advanced settings
StoredEncrypted at rest, never returned by any read endpoint
RotationChange 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.

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.

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

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)

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.

Retries

Anything other than a 2xx, including a timeout, is retried with backoff: 6 attempts over about an hour.

AttemptAfter
1immediately
215 s
31 m
45 m
515 m
61 h

Then the delivery is dead-lettered and visible in the panel, so you can see what failed instead of discovering a silent gap.

Do not rely on retries as your only safety net. After the last attempt the event is not redelivered automatically. Use the usage API to reconcile a gap.

Check your configuration

curl https://api-chat.parstechai.com/v1/webhooks \
-H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxx"
{
"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:

{
"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

EventFires when
messageA message is sent or received, including attachments
message.generatingThe AI has started composing a reply. Show a typing indicator
chatA conversation changes state
reactionA reaction is added or removed
is_typingA human on the other side starts or stops typing

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.

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

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.

The ParsChat message_id this message replies to, so you can render the quoted message. null when the message is not a reply.

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.

A list of {type, situation, key, value}, present when type is form.

{
"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.