Gareth Wilson Gareth Wilson

Guide to Linq Webhooks: Features and Best Practices

Published


Linq 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

FeatureDetails
ConfigurationAPI only, POST /webhook-subscriptions
FiltersExact event types, plus optional phone_numbers for message-style events
Payload versions2025-01-01 or 2026-02-03, pinned on the target URL
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature, with webhook-id and webhook-timestamp
SecretPer subscription, whsec_..., returned once and never changeable
BodyJSON
Event routingBody field event_type
Response timeout10 seconds
RetriesAbout 10 attempts within 30 minutes of the event
Source IPsNot published

Events and payloads

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

EventTriggered WhenCommon Use Cases
message.receivedAn incoming message arrives on your lineRoute inbound messages to an agent, CRM or bot
message.sentA message was successfully sent from your lineConfirm sends, store the message ID
message.deliveredThe message reached the recipient's deviceDelivery receipts (never sent for SMS/MMS)
message.readThe recipient read the messageRead receipts (never sent for SMS/MMS)
message.failedDelivery failedRetry or alert; branch on code
message.editedA message was edited (2026-02-03 webhook version only)Keep stored copies in sync
reaction.addedA tapback or emoji reaction was added to a message partEngagement signals
reaction.removedA reaction was removedEngagement signals
chat.createdA chat was createdTrack new conversations
participant.addedSomeone joined a group chatKeep group membership in sync
participant.removedSomeone left or was removed from a group chatKeep group membership in sync
phone_number.status_updatedA line's status (ACTIVE / FLAGGED) or reputation (HEALTHY / AT_RISK / CRITICAL) changedAlert 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:

FieldDescription
api_versionAPI version (v3)
webhook_versionPayload version, 2025-01-01 or 2026-02-03 (see below)
event_typeEvent type string. The deprecated X-Webhook-Event header carries the same value
event_idUnique event ID. Use it for deduplication: it is the same on retries and across subscriptions
created_atWhen the event was created
trace_idTrace ID for debugging and support requests
partner_idYour partner identifier
dataThe 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:

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

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:

ActionEndpoint
ListGET /webhook-subscriptions
RetrieveGET /webhook-subscriptions/{id}
UpdatePUT /webhook-subscriptions/{id}: change target_url, subscribed_events, phone_numbers, is_active, routing headers. Omitted fields keep their values
DeleteDELETE /webhook-subscriptions/{id}
List event typesThe 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 forwards the source to your machine:

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.

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:

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 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 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 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 extends delivery past Linq's retry window and keeps conversation events safe through outages. Try Hookdeck for free.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.