> ## Documentation Index
> Fetch the complete documentation index at: https://docs.linkiasoft.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> We POST to your endpoint when something happens — messages, comments, and leads moving through your pipeline.

Instead of polling us for changes, register an endpoint and we'll POST to it as things
happen. A message arriving, a lead reaching your Won stage, a comment on a Facebook post —
each becomes an HTTP request to a URL you control.

This is what you want if you're driving n8n, Zapier, Make, or anything you've written
yourself. See [the n8n guide](/guides/n8n-lead-webhook) for a worked example.

**Webhooks are configured in the app, not through the API.** Go to
**Integrations → Webhooks**. An endpoint is somewhere we send your customers' data, so
creating one is deliberately an administrator action rather than something an API key can do.

## Setting one up

<Steps>
  <Step title="Add the endpoint">
    **Integrations → Webhooks → New endpoint**. Give it a name and a publicly reachable
    `https://` URL.
  </Step>

  <Step title="Choose your events">
    Tick the events you want. For each one you can also tick which fields the payload
    should carry — see [choosing fields](#choosing-which-fields-to-send).
  </Step>

  <Step title="Save the signing secret">
    It's shown **once**, on creation, and starts with `whsec_`. You need it to
    [verify signatures](#verifying-the-signature). Store it in your secret manager before
    closing the dialog.
  </Step>

  <Step title="Send a test">
    Use **Send test** on the endpoint's page. It goes through the real delivery path —
    same signing, same logging — so a test that arrives proves the setup works.
  </Step>
</Steps>

<Warning>
  The URL must resolve to a public address. Private ranges, `localhost`, link-local and cloud
  metadata addresses are rejected, both when you save the endpoint and again on every
  delivery — so a hostname that is later repointed inward stops working rather than becoming
  a way into our network.

  Testing locally? Use a tunnel such as ngrok or n8n's own test URL, not `http://localhost`.
</Warning>

## Events

| Event                | Fires when                                                     |
| -------------------- | -------------------------------------------------------------- |
| `message.received`   | A customer sends a message on any connected channel            |
| `message.sent`       | An agent, bot or automation sends a message                    |
| `message.delivered`  | The platform confirms an outbound message reached the customer |
| `message.read`       | The customer reads an outbound message                         |
| `message.failed`     | An outbound message could not be delivered                     |
| `comment.received`   | Someone comments on a Facebook or Instagram post               |
| `lead.created`       | A lead is created, from any source                             |
| `lead.updated`       | A lead's details are edited                                    |
| `lead.stage.changed` | A lead moves to a different pipeline stage                     |
| `lead.assigned`      | A lead is assigned to a different user                         |
| `lead.won`           | A lead reaches a stage marked as won                           |
| `lead.lost`          | A lead reaches a stage marked as lost                          |

<Note>
  Editing a lead's stage fires **both** `lead.updated` and `lead.stage.changed`. Subscribe to
  the one you actually mean — taking both means handling each move twice.

  `lead.won` and `lead.lost` follow the won/lost flags on your pipeline stages, not the stage
  name. If you renamed "Won" to "Closed — signed", it still fires.
</Note>

## The payload

Every delivery has the same envelope. Only `data` differs by event:

```json theme={null}
{
  "id": "6f1c8e2a-9b4d-4c7e-8a13-2d5f0b7c9e41",
  "event": "lead.stage.changed",
  "source": "crm",
  "tenant_id": "8f6a2b3c-4d5e-4a7b-9c1e-3f2b1a9c0d4e",
  "occurred_at": "2026-07-26T09:14:03.221Z",
  "data": {
    "lead_id": "b21e7d40-3a55-4f89-9c02-77ab1e6d3c88",
    "name": "Nadia Farouk",
    "status": "negotiation",
    "previous_status": "qualified",
    "value": 25000,
    "currency": "EGP"
  }
}
```

| Field         | What it is                                                                             |
| ------------- | -------------------------------------------------------------------------------------- |
| `id`          | Unique per delivery. Use it to make your handler idempotent — see [retries](#retries). |
| `event`       | Which event fired.                                                                     |
| `source`      | `crm` or `social` — which side of the product produced it.                             |
| `occurred_at` | When the event happened, not when we sent it. Retries keep the original.               |
| `data`        | The event's fields, narrowed to what you subscribed to.                                |

`source` is worth using if your workflow writes back into Linkiasoft: it lets you tell a
change your own automation made from one a person made, which is how you avoid a loop.

## Choosing which fields to send

Each event has a set of fields, and you pick which ones we include. Untick anything the
receiving system doesn't need — particularly `text`, `customer_phone` and `email`, which
are the fields you're least likely to want sitting in a third-party tool's logs.

Leaving **all** fields ticked is not the same as listing them out. All-ticked means "send
whatever this event carries", so a field we add later is included automatically. If you
untick even one, you get exactly the set you chose and nothing new.

## Verifying the signature

Every request carries these headers:

| Header               | Example                 |
| -------------------- | ----------------------- |
| `X-Linkia-Signature` | `t=1753500000,v1=5f3a…` |
| `X-Linkia-Event`     | `lead.stage.changed`    |
| `X-Linkia-Delivery`  | the envelope's `id`     |
| `X-Linkia-Attempt`   | `1`                     |

`v1` is an HMAC-SHA256, keyed with your signing secret, over the string
`` `${t}.${rawBody}` `` — the timestamp, a literal dot, then the **raw** request body.

<Warning>
  Sign the raw body exactly as received. If your framework parses the JSON and you
  re-serialise it before hashing, key order or whitespace will differ and every signature
  will fail. Most frameworks need to be told to keep the raw body.
</Warning>

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  function verify(rawBody, header, secret, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
      header.split(',').map((p) => p.trim().split('='))
    );

    const timestamp = Number(parts.t);
    if (!Number.isFinite(timestamp)) return false;

    // Reject anything too old to be a live delivery — this is what stops someone
    // replaying a payload they captured earlier.
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(parts.v1 ?? '');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      parts = dict(p.strip().split("=", 1) for p in header.split(","))

      try:
          timestamp = int(parts["t"])
      except (KeyError, ValueError):
          return False

      if abs(time.time() - timestamp) > tolerance:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, parts.get("v1", ""))
  ```

  ```php PHP theme={null}
  function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
      $parts = [];
      foreach (explode(',', $header) as $piece) {
          [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, null);
          $parts[$k] = $v;
      }

      if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
      if (abs(time() - (int) $parts['t']) > $tolerance) return false;

      $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
      return hash_equals($expected, $parts['v1']);
  }
  ```
</CodeGroup>

Compare digests with a constant-time function — `timingSafeEqual`, `compare_digest`,
`hash_equals` — not `==`.

<Note>
  The timestamp is inside the signed string on purpose. Signing the body alone would leave
  every delivery you receive replayable by anyone who captured one, forever. Checking `t`
  against a tolerance is the half that makes it useful, so don't skip it.
</Note>

## Retries

Answer `2xx` and we consider the delivery done. Anything else:

| Response                    | What happens                                                                      |
| --------------------------- | --------------------------------------------------------------------------------- |
| `2xx`                       | Success. The failure streak resets.                                               |
| `4xx`                       | **Terminal.** You've told us the request is wrong — replaying it changes nothing. |
| `5xx`, `429`, `408`         | Retried.                                                                          |
| Timeout or connection error | Retried. We wait 10 seconds for a response.                                       |

Retries back off: **30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours** — five attempts
across roughly eight hours, then we stop. Every attempt appears in the log with its own
`X-Linkia-Attempt` number, and all attempts at one delivery share the same envelope `id`.

<Warning>
  Delivery is **at least once**. A network failure after your handler succeeded but before
  your `2xx` reached us means you'll see the same event again. If reprocessing would double
  a charge, send a duplicate message, or create a second record, deduplicate on the envelope
  `id` — it is stable across every attempt.
</Warning>

**Twenty consecutive failures disables the endpoint.** We stop calling it, the page shows
why, and events stop being queued for it. This exists so an abandoned test URL doesn't
generate failing deliveries forever. Fix the endpoint and press **Enable again** — that
clears the streak. Nothing is replayed automatically; use the log to re-send what matters.

## The delivery log

Each endpoint keeps its **most recent 300 attempts**, with the exact body we sent, the
first 2KB of your response, the status code, and how long it took. Open one to see the
request and response side by side, or press the retry icon to send it again.

Lifetime totals — how many requests we've ever made and how many failed — are counted
separately and are **not** affected by the 300-row limit.

<Note>
  The log is a debugging tool, not an archive. On a busy endpoint 300 attempts can be under
  an hour. If you need durable history, record deliveries on your side keyed by envelope `id`.
</Note>

## Custom headers

If your endpoint sits behind a proxy that wants its own header, add it under the
endpoint's settings. Headers we set ourselves — `Content-Type`, `User-Agent` and every
`X-Linkia-*` header — can't be overridden, since a custom header that could replace the
signature would make deliveries trivially forgeable.

## Rotating the secret

**Rotate secret** on the endpoint page issues a new one and shows it once.

<Warning>
  There is no overlap window. The old secret stops verifying the moment the new one is
  issued, so deploy the new secret to your receiver first, or expect failures in between.
  Those failures count toward the auto-disable streak.
</Warning>

## Things to watch in production

**A slow endpoint costs you events, not us.** We wait 10 seconds. If your handler does real
work — calling other APIs, writing to a slow database — answer `2xx` immediately and process
in the background. A handler that answers in 11 seconds looks identical to one that is down.

**Don't return `4xx` for your own problems.** A `422` because your validation is too strict
is terminal on our side: we won't retry, and the event is gone. Return `5xx` for anything
you'd want another attempt at.

**Test deliveries look like real ones.** `webhook.test` arrives through the same path with a
valid signature. Handle or ignore the event name; don't assume every delivery is a real
domain event.
