Lynkist Developers

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

  1. You register an endpoint URL in the dashboard or with the Webhook management API, and subscribe it to event types.
  2. When a subscribed event happens, Lynkist sends a signed JSON body to your URL.
  3. Your endpoint answers with a 2xx status in 10 seconds or less.
  4. If it does not, Lynkist retries up to 5 more times.
  5. 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"
  }
}
FieldMeaning
idThe unique id of the event. It is the same on every retry — use it to remove duplicates.
typeThe event type.
api_versionThe exact version of this payload. See Versioning.
created_atWhen the event happened (UTC, ISO 8601).
tenant.idYour workspace.
dataThe 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

HeaderValue
Content-Typeapplication/json
User-AgentLynkist-Webhook/1.0
webhook-idThe event id (the same as id in the body, and the same on every retry).
webhook-timestampUnix seconds of this attempt.
webhook-signaturev1,<base64> — see Verify the signature.
Lynkist-EventThe event type, for example message.received.
Lynkist-VersionThe exact version, for example 1.0.
Lynkist-Delivery-IdThe id of this delivery in the delivery log.
Lynkist-Webhook-IdThe 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 valid

Without 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_at and the timestamps in data.object.
  • Timeout. Answer in 10 seconds or less. Accept the event first, then process it.
  • Retries. A non-2xx answer or a timeout is retried on this schedule:
AttemptDelay before this attempt
1— (first attempt)
21 min
35 min
430 min
52 hr
66 hr

After the 6th failed attempt the delivery is abandoned. It stays in the delivery log, where you can send it again.

  • 410 Gone turns the endpoint off. If your endpoint answers 410, 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

EventWhendata
contact.createdA contact was created — by a person, the API, an import, or by Lynkist itself.object: Contact, source
contact.updatedA field of a contact changed.object: Contact
contact.deletedA contact was archived.object: Contact
contact.note_addedA note was added to a contact.object: Note
contact.communication_addedA call, email or message was logged on a contact.object: Communication

source of contact.created says how the contact was created:

sourceMeaning
dashboardA team member created it.
apiYour API key created it.
importIt came from a file import.
whatsapp_inboundA new person sent a WhatsApp message.
whatsapp_outboundYou sent a WhatsApp message to a new number.
webchatA new visitor wrote in a website chat.
integrationAn 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

EventWhendata
followup.createdA follow-up was scheduled.object: Follow-up
followup.updatedA follow-up was changed or completed.object: Follow-up
followup.deletedA 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

EventWhen
message.receivedA customer sent you a message. One event for each message.
message.sentAn outbound message was accepted.
message.deliveredThe message reached the customer's device (WhatsApp only).
message.readThe customer opened the message (WhatsApp only).
message.failedThe 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"
  }
}
FieldMeaning
idLynkist's id of the message.
channelwhatsapp or webchat.
channel_account_idWhich WhatsApp number or website chat widget.
directioninbound or outbound.
typetext, image, video, document, interactive, button, location, order, …
textThe text the person wrote: the body, a caption, or the title of a button reply. null when the message has no text.
contactid, phone and name of the customer. A field is null when Lynkist does not have it.
provider_message_idThe WhatsApp message id (wamid). The same value that POST /messages/template returns as message_id.
statusFor outbound messages: sent, delivered, read or failed.
sent_byFor outbound messages: human, ai_agent, chatbot, api, campaign, integration or system.
errorFor message.failed: why the message failed.
timestampWhen 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

EventWhendata
flow.submission_receivedA 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

EventWhendata
order.receivedA customer sent a cart from your WhatsApp catalog.object: Order
order.status_changedA 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

EventWhen
webhook.testYou 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 2xx fast. 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 a webhook.test event at once and returns what your endpoint answered. It also works on a disabled endpoint.
Webhooks | Lynkist Developers