Webhooks
Receive signed, versioned events for messages, contacts, Flows, orders, follow-ups and campaigns.
Webhooks let you react to events inside Lynkist without polling. When something happens — a
customer sends a message, a contact is created, a campaign completes — Lynkist sends a signed
JSON payload with an HTTP POST to an HTTPS endpoint that you register.
This page is the receive-side contract of webhook version 1: the events, the payloads,
the headers, the signature and the delivery rules. To create and manage endpoints, see the
Webhook management API. For how versions work, see
Versioning.
Lynkist webhooks follow the Standard Webhooks specification, so you can verify them with the open-source Standard Webhooks libraries.
How it works
- You register an endpoint URL in the dashboard or with the Webhook management API, and subscribe it to event types.
- When a subscribed event happens, Lynkist sends a signed JSON body to your URL.
- Your endpoint answers with a
2xxstatus in 10 seconds or less. - If it does not, Lynkist retries up to 5 more times.
- After 20 failures in a row, the endpoint is disabled until you turn it on again.
The event envelope
Every delivery has the same envelope. Only data changes with the event type.
{
"id": "evt_3f9a1c2b4d5e6f70",
"type": "contact.created",
"api_version": "1.0",
"created_at": "2026-11-14T10:15:00.000Z",
"tenant": {
"id": "5b1e7c1e-2f6a-4c11-9a43-0d6f0c9f3a21"
},
"data": {
"object": {
"id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"first_name": "Asha",
"last_name": "Rao",
"phone": "+919800000010",
"email": "asha@example.com",
"company": "Acme Store",
"job_title": null,
"status": "active",
"tags": [
"vip"
],
"custom_fields": {
"plan": "pro"
},
"created_at": "2026-11-14T10:00:00Z",
"updated_at": "2026-11-14T10:00:00Z"
},
"source": "whatsapp_inbound"
}
}| Field | Meaning |
|---|---|
id | The unique id of the event. It is the same on every retry — use it to remove duplicates. |
type | The event type. |
api_version | The exact version of this payload. See Versioning. |
created_at | When the event happened (UTC, ISO 8601). |
tenant.id | Your workspace. |
data | The event data. data.object is the resource the event is about. |
data.object has the same shape that the Public API returns for that resource. Some events add
fields beside object that describe the event itself, for example source or previous_status.
Ignore fields and event types that you do not know. A minor version can add them.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Lynkist-Webhook/1.0 |
webhook-id | The event id (the same as id in the body, and the same on every retry). |
webhook-timestamp | Unix seconds of this attempt. |
webhook-signature | v1,<base64> — see Verify the signature. |
Lynkist-Event | The event type, for example message.received. |
Lynkist-Version | The exact version, for example 1.0. |
Lynkist-Delivery-Id | The id of this delivery in the delivery log. |
Lynkist-Webhook-Id | The id of your endpoint. |
A deprecated version also sends Deprecation, Sunset and Link
(Versioning).
A test event (POST /webhooks/{id}/test) has the same headers and the same signature as a real
event. The reachability check that the dashboard runs before an endpoint exists is the only
request without a signature; it has Lynkist-Verification: true.
Verify the signature
The signature is an HMAC-SHA256 of this text, with the three parts joined by a full stop:
{webhook-id}.{webhook-timestamp}.{raw request body}The key is the base64-decoded part of your signing secret after the whsec_ prefix. The
header value is v1, followed by the base64 of the HMAC. The header can hold more than one
signature, separated by a space (see Rotate a secret); accept the delivery
when one of them matches.
Always use the raw request body. If you parse the JSON and serialise it again, the signature does not match.
With a Standard Webhooks library
import { Webhook } from 'standardwebhooks'
const wh = new Webhook(process.env.LYNKIST_WEBHOOK_SECRET!) // "whsec_…"
// Throws if the signature is wrong or the timestamp is too old.
const event = wh.verify(rawBody, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
})from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["LYNKIST_WEBHOOK_SECRET"]) # "whsec_…"
event = wh.verify(raw_body, request.headers) # raises if not validWithout a library
import base64, hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False # too old: refuse a replayed request
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
received = [s.split(",", 1)[1] for s in headers["webhook-signature"].split(" ") if "," in s]
return any(hmac.compare_digest(expected, s) for s in received)Verify before you trust. Without the check, anyone who finds your URL can send fake events.
Refuse a request whose signature does not match, and a request whose webhook-timestamp is
more than 5 minutes old.
Rotate a secret
When you rotate the secret, the old secret stays valid for 24 hours. In that time each delivery has two signatures, one for each secret. Change the secret in your system in that time, and no delivery fails.
Delivery rules
- At least once. An event can arrive more than once. Remove duplicates with
webhook-id. - No order guarantee. Events can arrive out of order. Use
created_atand the timestamps indata.object. - Timeout. Answer in 10 seconds or less. Accept the event first, then process it.
- Retries. A non-
2xxanswer or a timeout is retried on this schedule:
| Attempt | Delay before this attempt |
|---|---|
| 1 | — (first attempt) |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 hr |
| 6 | 6 hr |
After the 6th failed attempt the delivery is abandoned. It stays in the
delivery log, where you can send it again.
410 Goneturns the endpoint off. If your endpoint answers410, Lynkist disables it at once and does not retry. Use this when a URL will never accept events again.- 20 failures in a row turn the endpoint off. Turn it on again in the dashboard or with the API when the problem is fixed.
Event catalog
Every event below has a JSON Schema. Version 1 has 26 event types.
Contacts
| Event | When | data |
|---|---|---|
contact.created | A contact was created — by a person, the API, an import, or by Lynkist itself. | object: Contact, source |
contact.updated | A field of a contact changed. | object: Contact |
contact.deleted | A contact was archived. | object: Contact |
contact.note_added | A note was added to a contact. | object: Note |
contact.communication_added | A call, email or message was logged on a contact. | object: Communication |
source of contact.created says how the contact was created:
source | Meaning |
|---|---|
dashboard | A team member created it. |
api | Your API key created it. |
import | It came from a file import. |
whatsapp_inbound | A new person sent a WhatsApp message. |
whatsapp_outbound | You sent a WhatsApp message to a new number. |
webchat | A new visitor wrote in a website chat. |
integration | An integration created it. |
The Contact object is the same as in the Contacts API: id,
first_name, last_name, phone, email, company, job_title, status, tags,
custom_fields, created_at, updated_at.
contact.note_added:
{
"object": {
"id": "0b1f6a0e-2f0b-4a55-9d0e-1f2a3b4c5d6e",
"contact_id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"text": "Asked for a callback on Monday.",
"type": "general",
"is_important": false,
"created_at": "2026-11-14T10:05:00Z"
}
}contact.communication_added:
{
"object": {
"id": "5d0c2c0a-7b1e-4d3f-8a9b-0c1d2e3f4a5b",
"contact_id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"type": "call",
"direction": "outbound",
"status": "sent",
"content": "Confirmed the delivery address.",
"notes": null,
"occurred_at": "2026-11-14T10:10:00Z"
}
}Follow-ups
| Event | When | data |
|---|---|---|
followup.created | A follow-up was scheduled. | object: Follow-up |
followup.updated | A follow-up was changed or completed. | object: Follow-up |
followup.deleted | A follow-up was removed. | object: Follow-up |
{
"object": {
"id": "c2b7e6a4-1d3f-4b5a-9c8d-7e6f5a4b3c2d",
"contact_id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"due_at": "2026-11-17T09:00:00Z",
"type": "call",
"notes": "Call back about the order.",
"is_completed": false,
"completed_at": null,
"assigned_to": null
}
}Messages
| Event | When |
|---|---|
message.received | A customer sent you a message. One event for each message. |
message.sent | An outbound message was accepted. |
message.delivered | The message reached the customer's device (WhatsApp only). |
message.read | The customer opened the message (WhatsApp only). |
message.failed | The message could not be delivered (WhatsApp only). error says why. |
All five carry object: a Message. message.received:
{
"object": {
"id": "f1e2d3c4-b5a6-4789-8a9b-0c1d2e3f4a5b",
"channel": "whatsapp",
"channel_account_id": "b5113b01-c2fc-4f84-a1a7-96a255da586e",
"conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
"direction": "inbound",
"type": "text",
"text": "Is my order shipped?",
"contact": {
"id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"phone": "+919800000010",
"name": "Asha Rao"
},
"provider_message_id": "wamid.HBgMOTE5ODAwMDAwMDEwFQIAEhgg",
"status": null,
"sent_by": null,
"error": null,
"timestamp": "2026-11-14T10:15:00Z"
}
}| Field | Meaning |
|---|---|
id | Lynkist's id of the message. |
channel | whatsapp or webchat. |
channel_account_id | Which WhatsApp number or website chat widget. |
direction | inbound or outbound. |
type | text, image, video, document, interactive, button, location, order, … |
text | The text the person wrote: the body, a caption, or the title of a button reply. null when the message has no text. |
contact | id, phone and name of the customer. A field is null when Lynkist does not have it. |
provider_message_id | The WhatsApp message id (wamid). The same value that POST /messages/template returns as message_id. |
status | For outbound messages: sent, delivered, read or failed. |
sent_by | For outbound messages: human, ai_agent, chatbot, api, campaign, integration or system. |
error | For message.failed: why the message failed. |
timestamp | When the message was sent or its status changed. |
message.failed:
{
"object": {
"id": "f1e2d3c4-b5a6-4789-8a9b-0c1d2e3f4a5b",
"channel": "whatsapp",
"channel_account_id": "b5113b01-c2fc-4f84-a1a7-96a255da586e",
"conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
"direction": "outbound",
"type": "text",
"text": "Your order is on its way.",
"contact": {
"id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"phone": "+919800000010",
"name": "Asha Rao"
},
"provider_message_id": "wamid.HBgMOTE5ODAwMDAwMDEwFQIAEhgg",
"status": "failed",
"sent_by": "api",
"error": "Message undeliverable",
"timestamp": "2026-11-14T10:15:00Z"
}
}WhatsApp Flows
| Event | When | data |
|---|---|---|
flow.submission_received | A customer completed a WhatsApp Flow. | object: Flow submission |
{
"object": {
"id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"flow_id": "1234567890123456",
"flow_name": "Book a visit",
"channel_account_id": "b5113b01-c2fc-4f84-a1a7-96a255da586e",
"contact": {
"id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"phone": "+919800000010",
"name": "Asha Rao"
},
"answers": {
"name": "Asha",
"date": "2026-11-20",
"address.city": "Pune"
},
"submitted_at": "2026-11-14T10:20:00Z"
}
}answers holds the submitted fields as flat name: value pairs, so each field maps to one
column in your system. A nested answer becomes parent.child (address.city above).
Catalog orders
| Event | When | data |
|---|---|---|
order.received | A customer sent a cart from your WhatsApp catalog. | object: Order |
order.status_changed | A team member moved an order to another status. | object: Order, previous_status |
{
"object": {
"id": "7e6f5a4b-3c2d-4e1f-8a0b-9c8d7e6f5a4b",
"channel": "whatsapp",
"channel_account_id": "b5113b01-c2fc-4f84-a1a7-96a255da586e",
"conversation_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
"contact_id": "8a3646b9-8483-4dc9-9872-ff6a5d35a881",
"message_id": "f1e2d3c4-b5a6-4789-8a9b-0c1d2e3f4a5b",
"catalog_id": "987654321098765",
"items": [
{
"product_retailer_id": "SKU-1",
"quantity": 2,
"item_price": 49900,
"currency": "INR"
}
],
"item_count": 2,
"total_minor": 99800,
"currency": "INR",
"note": null,
"status": "confirmed",
"status_reason": null,
"status_changed_at": null,
"created_at": "2026-11-14T10:25:00Z",
"updated_at": "2026-11-14T10:25:00Z"
},
"previous_status": "new"
}An order is a request, not a payment: Lynkist takes no payment and changes no stock. Amounts
are integers in the minor unit of currency (total_minor: 99800 with INR is ₹998.00).
Campaigns
campaign.created, campaign.updated, campaign.scheduled, campaign.started,
campaign.paused, campaign.resumed, campaign.stopped, campaign.completed,
campaign.failed. All carry object: the Campaign with its counters at that time.
{
"object": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "Diwali offer",
"status": "RUNNING",
"template_id": "d4c3b2a1-f6e5-4b7a-9d8c-5c4b3a2f1e0d",
"template_name": "diwali_offer",
"scheduled_at": null,
"started_at": "2026-11-14T10:30:00Z",
"completed_at": null,
"audience_size": 1200,
"sent_count": 300,
"delivered_count": 280,
"read_count": 120,
"failed_count": 4,
"error_message": null
}
}Webhook lifecycle
| Event | When |
|---|---|
webhook.test | You sent a test event from the dashboard or with POST /webhooks/{id}/test. |
{
"id": "evt_3f9a1c2b4d5e6f70",
"type": "webhook.test",
"api_version": "1.0",
"created_at": "2026-11-14T10:15:00.000Z",
"tenant": {
"id": "5b1e7c1e-2f6a-4c11-9a43-0d6f0c9f3a21"
},
"data": {
"message": "This is a test event from Lynkist"
}
}Template events (template.approved, template.rejected, …) are not sent today. When they
are added, they come as a minor version — see the Changelog.
Best practices
- Answer
2xxfast. Accept the event, then process it in the background. - Verify the signature on every request.
- Remove duplicates with
webhook-id. - Subscribe only to the events you use. You can add more later.
- Ignore what you do not know. New fields and new event types can come in a minor version.
- Test with
POST /webhooks/{id}/test. It sends awebhook.testevent at once and returns what your endpoint answered. It also works on a disabled endpoint.