# Guide to Linq Webhooks: Features and Best Practices

[Linq](https://docs.linqapp.com/channel/imessage/) is a partner API for iMessage, RCS and SMS lines. This guide covers its iMessage channel, at `https://api.linqapp.com/api/partner/v3`. Conversations on a line are driven from outside your app, by people replying, reacting, voting in polls, calling or paying, so Linq pushes each of those to you as a webhook, along with changes to your account such as new API tokens and edited subscriptions.

## What are Linq webhooks?

Linq delivers events to **webhook subscriptions**. A subscription is a target URL you own, an exact list of `subscribed_events`, an optional `phone_numbers` filter, optional routing headers, and a signing secret. Each delivery is a JSON `POST` signed with Standard Webhooks headers.

Payloads are versioned. You pin the version per subscription with a query parameter on the target URL, `?version=2026-02-03` or `?version=2025-01-01`, and the body reports it as `webhook_version`. Every delivery also carries older `X-Webhook-*` headers with a hex HMAC, which Linq has deprecated in favour of the Standard Webhooks headers.

## Linq webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only, `POST /webhook-subscriptions` |
| Filters | Exact event types, plus optional `phone_numbers` for message-style events |
| Payload versions | `2025-01-01` or `2026-02-03`, pinned on the target URL |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature`, with `webhook-id` and `webhook-timestamp` |
| Secret | Per subscription, `whsec_...`, returned once and never changeable |
| Body | JSON |
| Event routing | Body field `event_type` |
| Response timeout | 10 seconds |
| Retries | About 10 attempts within 30 minutes of the event |
| Source IPs | Not published |

## Events and payloads

Linq lists 76 event types, rendered from its OpenAPI spec. These are the ones most messaging integrations handle:

| Event | Triggered When | Common Use Cases |
| --- | --- | --- |
| `message.received` | An incoming message arrives on your line | Route inbound messages to an agent, CRM or bot |
| `message.sent` | A message was successfully sent from your line | Confirm sends, store the message ID |
| `message.delivered` | The message reached the recipient's device | Delivery receipts (never sent for SMS/MMS) |
| `message.read` | The recipient read the message | Read receipts (never sent for SMS/MMS) |
| `message.failed` | Delivery failed | Retry or alert; branch on `code` |
| `message.edited` | A message was edited (`2026-02-03` webhook version only) | Keep stored copies in sync |
| `reaction.added` | A tapback or emoji reaction was added to a message part | Engagement signals |
| `reaction.removed` | A reaction was removed | Engagement signals |
| `chat.created` | A chat was created | Track new conversations |
| `participant.added` | Someone joined a group chat | Keep group membership in sync |
| `participant.removed` | Someone left or was removed from a group chat | Keep group membership in sync |
| `phone_number.status_updated` | A line's `status` (`ACTIVE` / `FLAGGED`) or `reputation` (`HEALTHY` / `AT_RISK` / `CRITICAL`) changed | Alert on line health |

The rest fall into these groups:

* **Messages and polls:** `poll.received`, `poll.sent`, `poll.delivered`, `poll.read`, `poll.updated`, `poll.failed`, `poll.vote.added`, `poll.vote.removed`, `poll.reaction.added`
* **Chats:** `chat.group_name_updated`, `chat.group_icon_updated`, `chat.group_name_update_failed`, `chat.group_icon_update_failed`, `chat.background_updated`, `chat.background_update_failed`, `chat.typing_indicator.started`, `chat.typing_indicator.stopped`
* **Phone numbers and contacts:** `phone_number.assigned`, `phone_number.released`, `phone_number.line_type_updated`, `phone_number.forwarding_updated`, `contact_card.received`, `contact_card.created`, `contact_card.updated`, `contact_card.deleted`
* **Calls:** `call.initiated`, `call.ringing`, `call.answered`, `call.ended`, `call.failed`, `call.declined`, `call.no_answer`
* **Location and payments:** `location.sharing.started`, `location.sharing.stopped`, `payment.succeeded`, `payment.canceled`, `payment.expired`, `payment.declined`, `payment.authorized`, `connection.created`, `connection.revoked`
* **Account events:** `zero_day_retention.updated`, `environment.line_moved`, `environment.created`, `environment.renamed`, `environment.deleted`, `api_token.created`, `api_token.renamed`, `api_token.expiry_scheduled`, `api_token.expired`, `api_token.activated`, `api_token.deleted`, `webhook_subscription.created`, `webhook_subscription.deleted`, `webhook_subscription.target_url_changed`, `webhook_subscription.enabled`, `webhook_subscription.disabled`, `webhook_subscription.events.updated`, `webhook_subscription.phone_numbers.updated`, `webhook_subscription.routing_headers_set`, `webhook_subscription.routing_headers_cleared`, `team_member.added`, `team_member.signed_in`, `team_member.signed_out`

Linq's webhook guide doesn't describe or give examples for 18 of these, among them the `call.*`, `payment.*` and `connection.*` events. For those, read the Webhook Events pages of the API reference.

Every event shares one envelope:

| Field | Description |
| --- | --- |
| `api_version` | API version (`v3`) |
| `webhook_version` | Payload version, `2025-01-01` or `2026-02-03` (see below) |
| `event_type` | Event type string. The deprecated `X-Webhook-Event` header carries the same value |
| `event_id` | Unique event ID. **Use it for deduplication**: it is the same on retries and across subscriptions |
| `created_at` | When the event was created |
| `trace_id` | Trace ID for debugging and support requests |
| `partner_id` | Your partner identifier |
| `data` | The event-specific payload |

`data` varies. In `2026-02-03` payloads, `message.received` and `message.sent` carry the message at `data.id` with a nested `data.chat`, `sender_handle` and `parts[]`, while `message.failed` and the reaction events carry flat `chat_id` and `message_id`. `phone_number.status_updated` has `phone_number`, `previous_status`, `new_status`, `previous_reputation`, `new_reputation` and `changed_at`. Account events put `summary`, `occurred_at`, `actor` and `origin` in `data`, as in this `api_token.activated` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/linq/2026-02-03/api_token.activated.json):

```json
{
  "api_version": "v3",
  "webhook_version": "2026-02-03",
  "event_type": "api_token.activated",
  "event_id": "5b2f0c1e-7a41-4d7e-9a53-0f6f1c9d0012",
  "created_at": "2026-09-25T18:23:44.512Z",
  "trace_id": "4f9a1c0d2b3e4f5a6b7c8d9e0f1a2b3c",
  "partner_id": "your-partner-id",
  "data": {
    "summary": "API token activated",
    "occurred_at": "2026-09-25T18:23:44.201Z",
    "api_token": {
      "id": "7c1e2f3a-4b5c-4d6e-8f90-1a2b3c4d5e6f",
      "name": "CI deploy",
      "token_prefix": "lq_live_3f9a",
      "expires_at": null
    },
    "actor": {
      "type": "team_member",
      "id": "8d2e4f60-1b3c-4a5d-9e7f-0a1b2c3d4e5f",
      "name": "Maria Lopez",
      "email": "maria@acme.com"
    },
    "origin": "dashboard"
  }
}

```

## Setting up Linq webhooks

Create a subscription with your Partner API key. This one pins the `2026-02-03` payload version and subscribes to the core message events:

```bash
curl -X POST https://api.linqapp.com/api/partner/v3/webhook-subscriptions \
  -H "Authorization: Bearer $LINQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://webhooks.example.com/webhooks/linq?version=2026-02-03",
    "subscribed_events": ["message.received", "message.sent", "message.delivered", "message.read", "message.failed"]
  }'

```

The response includes `signing_secret`, for example `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Jxx2Oll+OE=`. Linq can't show it to you again, so put the full value in `LINQ_WEBHOOK_SECRET` now.

* `target_url` must be unique within your account. To send different lines to different endpoints, vary the URL, for example `?line=support`.
* `subscribed_events` is an exact list. There are no wildcards, so account events such as `api_token.created` are listed the same way.
* `phone_numbers` limits message-style events to specific lines. It doesn't apply to account events.

Manage subscriptions with these calls:

| Action | Endpoint |
| --- | --- |
| List | `GET /webhook-subscriptions` |
| Retrieve | `GET /webhook-subscriptions/{id}` |
| Update | `PUT /webhook-subscriptions/{id}`: change `target_url`, `subscribed_events`, `phone_numbers`, `is_active`, routing headers. Omitted fields keep their values |
| Delete | `DELETE /webhook-subscriptions/{id}` |
| List event types | The Webhook Events endpoint in the API reference |

To route Linq through Hookdeck, create a source with the **Linq** type, add the signing secret, and use the source URL, with its `?version=` parameter, as `target_url`. For local development, the [Hookdeck CLI](/docs/cli) forwards the source to your machine:

```bash
npx hookdeck-cli listen 3000 linq --path /webhooks/linq

```

Text the line from another phone and check that `message.received` arrives.

## Securing Linq webhooks

Linq's steps: take `webhook-id`, `webhook-timestamp` and the raw body, join them with dots, compute HMAC-SHA256 with the base64-decoded secret after `whsec_`, and compare the base64 result with each `v1,` entry in `webhook-signature`. Linq is explicit about not parsing and re-serialising the body first. Ignore the deprecated `X-Webhook-Signature`.

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Linq's guide also says to reject timestamps more than five minutes old. Linq's own retries finish within 30 minutes of the event, but Hookdeck has no timestamp tolerance and replays or retries with the original `webhook-timestamp`, so behind Hookdeck that check would turn away genuine events. The verifier above leaves it out.

This receiver stores each event under `event_id` and answers inside Linq's 10-second timeout:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// conversationLog.write(eventId, event) stores the event once and resolves when durable.
function createLinqReceiver(conversationLog) {
  const app = express();
  app.post('/webhooks/linq', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.LINQ_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const event = parseJsonObject(req.body);
    if (!event) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await conversationLog.write(event.event_id, event);
    } catch {
      return res.sendStatus(503); // Linq retries 5xx; most 4xx are final
    }
    return res.sendStatus(200);
  });
  return app;
}

```

Linq's retries stop on most `4xx` responses, including the `401` above, so a misconfigured secret loses events quickly. With a Hookdeck source in front, Linq always gets `200`, and requests whose signature fails are recorded in Hookdeck as failed verification.

> **Make Linq webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) holds every Linq event past the 30-minute retry window and gives you a history to replay from.

## Linq webhook limitations and pain points

### Thirty minutes of retries

**The Problem:** An endpoint outage longer than half an hour loses messages for good.

**Why It Happens:** Linq makes about 10 attempts with jittered backoff capped at 10 minutes, all within 30 minutes of the event's creation. Other `4xx` responses, DNS failures and `https`-to-`http` redirects aren't retried at all.

**Workarounds:**

* Keep the receiver minimal and highly available, and return `5xx` for anything temporary.
* Recover conversations from the API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts each event while your service is down and [retries](/docs/retries) it for as long as your policy allows.

### Redirects look like success

**The Problem:** A delivery can be marked successful even though your handler never ran.

**Why It Happens:** Linq follows `301`, `302` and `303` redirects as a `GET` with no body. The delivery can then count as successful even though your handler never received the event.

**Workarounds:**

* Register the final `https://` URL, with no trailing-slash or host redirects in front of it.

**How Hookdeck Can Help:** A Hookdeck source URL doesn't redirect, and Hookdeck forwards a full `POST` to your service.

### Secrets can't be rotated in place

**The Problem:** A leaked or lost secret means rebuilding the subscription.

**Why It Happens:** The secret is only returned on create, and `PUT` can't change it. The only way to get a new one is to delete the subscription and create another.

**Workarounds:**

* Create the replacement first. `target_url` must be unique within an account, so give it a different URL, for example with an extra query parameter.
* Have the receiver accept both secrets until the old subscription is deleted, and deduplicate on `event_id` while both deliver.

**How Hookdeck Can Help:** Pointing both subscriptions at different Hookdeck source URLs lets each verify with its own secret while your service sees one deduplicated stream.

### Account events arrive out of order

**The Problem:** `api_token.renamed` is processed before `api_token.created`.

**Why It Happens:** Linq doesn't guarantee ordering for account events.

**Workarounds:**

* Order account events by `data.occurred_at`.

**How Hookdeck Can Help:** Hookdeck keeps every account event in its log, so you can audit and replay the sequence when a later step went wrong.

## Best practices

### Deduplicate on `event_id`

Linq delivers at least once, and `event_id` is the same across retries and across every subscription that receives the event. The [idempotency guide](/webhooks/guides/implement-webhook-idempotency) covers how to store it.

### Don't wait for read receipts on SMS

SMS and MMS sends never produce `message.delivered` or `message.read`. Treat `message.sent` as the last status for those lines.

### Pin the payload version

Always set `?version=` on `target_url`. Without it the subscription takes whatever version is latest when you create it, and some events, such as `message.edited`, exist only in `2026-02-03`.

### Watch line reputation

`phone_number.status_updated` reports a line moving to `FLAGGED` or to an `AT_RISK` or `CRITICAL` reputation. Alert on it before sending volume through a struggling number.

## Conclusion

Linq's webhooks report messages, calls, payments and account audit events with Standard Webhooks signing. Retries stop after 30 minutes, so put a durable queue in front of anything slow.

[Hookdeck Event Gateway](https://hookdeck.com) extends delivery past Linq's retry window and keeps conversation events safe through outages. [Try Hookdeck for free](https://dashboard.hookdeck.com/signup).