Guide to Linq Webhooks: Features and Best Practices
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
| 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_ | 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_ 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_. Linq can't show it to you again, so put the full value in LINQ_WEBHOOK_SECRET now.
target_urlmust be unique within your account. To send different lines to different endpoints, vary the URL, for example?line=support.subscribed_eventsis an exact list. There are no wildcards, so account events such asapi_token.createdare listed the same way.phone_numberslimits 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 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
5xxfor 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_urlmust 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_idwhile 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_ 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.