feat(linq): audit fixes + native auto-registering webhook trigger (#5301)

* feat(linq): audit fixes + native auto-registering webhook trigger

Tools/block audit (validated against the live Linq partner API + OpenAPI spec):
- create_chat: read the sent message from top-level response.message (was always null)
- get_message/edit_message: expose canonical deliveryStatus; mark is_delivered/is_read deprecated
- send_message: fall back to from_handle.service/preferred_service for service output
- list_phone_numbers: migrate deprecated health_status to reputation; add forwardingNumber; guard JSON parse
- check_imessage/check_rcs: guard response.json() parse
- mark_chat_read: note 1:1-only / group no-op behavior

Native webhook trigger (auto register + deregister):
- 6 triggers (message received/delivered/failed/read, reaction added, all-events)
- Standard Webhooks signature verification (HMAC-SHA256, whsec_ secret)
- createSubscription/deleteSubscription manage the Linq subscription lifecycle
- event_id idempotency; full 27-value WebhookEventType enum for all-events
- regenerated docs

* refactor(linq): drop phantom create_chat response path, complete deliveryStatus enum doc

Final validation against the raw Linq OpenAPI spec confirmed the sent message
is at chat.message (CreateChatResult exposes only chat), so the data.message
fallback was dead code. Also list all 7 DeliveryStatus values in the
send_message output description.

* fix(linq): namespace trigger credential keys, handle edit_message 204, nullable forwardingNumber

Review + final pre-merge audit fixes:
- Trigger apiKey/phoneNumbers subblocks collided with the block's tool apiKey
  state key — rename to triggerApiKey/triggerPhoneNumbers (per the namespacing
  rule from #2133) and read them in the webhook handler
- edit_message: the API returns 204 No Content when editing an already-deleted
  message; guard the empty body instead of throwing on response.json()
- list_phone_numbers: mark forwardingNumber output nullable (returns null)
- check_rcs: tighten address hint (RCS is phone-only, not email)
- regenerated docs

* fix(linq): read triggerApiKey in webhook deleteSubscription

deleteSubscription still read config.apiKey after the credential rename, so
undeploy would skip the DELETE and orphan the Linq subscription. Match
createSubscription's triggerApiKey key.

* fix(linq): mark list_phone_numbers healthStatus output nullable

healthStatus returns null when Linq omits reputation/health_status; declare
nullable: true to match the runtime value (same as forwardingNumber).

* fix(linq): mark nullable list_webhook_subscriptions item fields

phoneNumbers/createdAt/updatedAt are null-coerced by mapWebhookSubscription;
declare nullable: true on the array-item schema to match runtime (consistent
with the top-level webhook outputs' optional flags).
This commit is contained in:
Waleed
2026-06-30 17:18:57 -07:00
committed by GitHub
parent 604d03e40d
commit c1b84e4eec
23 changed files with 960 additions and 47 deletions
+157 -8
View File
@@ -88,7 +88,7 @@ Check whether an address (phone number or email) supports RCS
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `apiKey` | string | Yes | Linq API key |
| `address` | string | Yes | Phone number \(E.164 format\) or email address to check |
| `address` | string | Yes | Phone number \(E.164 format\) to check |
| `from` | string | No | Sender phone number to check from \(defaults to an available number\) |
#### Output
@@ -277,8 +277,9 @@ Edit the text of a sent message (up to 5 times, within 15 minutes of sending; iM
| `id` | string | Message ID |
| `chatId` | string | ID of the chat the message belongs to |
| `isFromMe` | boolean | Whether the message was sent by you |
| `isDelivered` | boolean | Whether the message was delivered |
| `isRead` | boolean | Whether the message was read |
| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) |
| `isDelivered` | boolean | Whether the message was delivered \(deprecated; use deliveryStatus\) |
| `isRead` | boolean | Whether the message was read \(deprecated; use deliveryStatus\) |
| `service` | string | Delivery service \(iMessage, SMS, RCS\) |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 update timestamp |
@@ -374,8 +375,9 @@ Retrieve a single message by ID, including parts, reactions, and delivery status
| `id` | string | Message ID |
| `chatId` | string | ID of the chat the message belongs to |
| `isFromMe` | boolean | Whether the message was sent by you |
| `isDelivered` | boolean | Whether the message was delivered |
| `isRead` | boolean | Whether the message was read |
| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) |
| `isDelivered` | boolean | Whether the message was delivered \(deprecated; use deliveryStatus\) |
| `isRead` | boolean | Whether the message was read \(deprecated; use deliveryStatus\) |
| `service` | string | Delivery service \(iMessage, SMS, RCS\) |
| `createdAt` | string | ISO 8601 creation timestamp |
| `updatedAt` | string | ISO 8601 update timestamp |
@@ -483,7 +485,8 @@ List all phone numbers assigned to your partner account, with line health
| `phoneNumbers` | array | Phone numbers assigned to the account |
| ↳ `id` | string | Phone number ID |
| ↳ `phoneNumber` | string | Phone number in E.164 format |
| ↳ `healthStatus` | json | Line health status \(status, doc_url\) |
| ↳ `forwardingNumber` | string | Forwarding number in E.164 format, or null |
| ↳ `healthStatus` | json | Line reputation/health status \(status, doc_url\) |
### `linq_list_thread`
@@ -548,7 +551,7 @@ List all webhook subscriptions on your account
### `linq_mark_chat_read`
Mark all messages in a chat as read
Mark messages in a chat as read (only applies to 1:1 iMessage/RCS; no effect on group chats)
#### Input
@@ -633,7 +636,7 @@ Send a message to an existing chat, with optional media, link, effect, or reply
| --------- | ---- | ----------- |
| `chatId` | string | ID of the chat the message was sent to |
| `messageId` | string | ID of the sent message |
| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, failed\) |
| `deliveryStatus` | string | Delivery status \(pending, queued, sent, delivered, received, read, failed\) |
| `sentAt` | string | ISO 8601 timestamp the message was sent |
| `service` | string | Delivery service \(iMessage, SMS, RCS\) |
| `message` | json | The full sent message object with parts |
@@ -785,3 +788,149 @@ Update a webhook subscription (target URL, events, phone filter, or active state
| `updatedAt` | string | ISO 8601 update timestamp |
## Triggers
A **Trigger** is a block that starts a workflow when an event happens in this service.
### Linq Message Delivered
Trigger workflow when a message is delivered
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
---
### Linq Message Failed
Trigger workflow when a message fails to deliver
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
---
### Linq Message Read
Trigger workflow when a message is read
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
---
### Linq Message Received
Trigger workflow when an inbound message is received
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
---
### Linq Reaction Added
Trigger workflow when a reaction is added to a message
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
---
### Linq Webhook (All Events)
Trigger on any Linq webhook event (messages, reactions, chats, and more)
#### Configuration
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `triggerApiKey` | string | Yes | Required to create the webhook subscription in Linq. |
| `triggerPhoneNumbers` | string | No | Comma-separated E.164 numbers to restrict which numbers deliver events. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `eventType` | string | Event type \(e.g. message.received, message.delivered, reaction.added\) |
| `eventId` | string | Unique event identifier used for deduplication |
| `createdAt` | string | ISO 8601 timestamp of when the event occurred |
| `webhookVersion` | string | Payload schema version of the delivered event |
| `data` | json | Full event payload \(shape varies by event type — message, reaction, chat, etc.\) |
+19
View File
@@ -2,6 +2,7 @@ import { LinqIcon } from '@/components/icons'
import type { BlockConfig, BlockMeta } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
import { getTrigger } from '@/triggers'
const CHAT_ID_OPS = [
'get_chat',
@@ -559,6 +560,12 @@ export const LinqBlock: BlockConfig = {
condition: { field: 'operation', value: [...PAGINATION_OPS] },
mode: 'advanced',
},
...getTrigger('linq_message_received').subBlocks,
...getTrigger('linq_message_delivered').subBlocks,
...getTrigger('linq_message_failed').subBlocks,
...getTrigger('linq_message_read').subBlocks,
...getTrigger('linq_reaction_added').subBlocks,
...getTrigger('linq_webhook').subBlocks,
],
tools: {
@@ -863,6 +870,18 @@ export const LinqBlock: BlockConfig = {
events: { type: 'json', description: 'Available webhook event types' },
docUrl: { type: 'string', description: 'Documentation URL' },
},
triggers: {
enabled: true,
available: [
'linq_message_received',
'linq_message_delivered',
'linq_message_failed',
'linq_message_read',
'linq_reaction_added',
'linq_webhook',
],
},
}
export const LinqBlockMeta = {
+37 -6
View File
@@ -10003,7 +10003,7 @@
},
{
"name": "Mark Chat as Read",
"description": "Mark all messages in a chat as read"
"description": "Mark messages in a chat as read (only applies to 1:1 iMessage/RCS; no effect on group chats)"
},
{
"name": "Leave Chat",
@@ -10119,8 +10119,39 @@
}
],
"operationCount": 34,
"triggers": [],
"triggerCount": 0,
"triggers": [
{
"id": "linq_message_received",
"name": "Linq Message Received",
"description": "Trigger workflow when an inbound message is received"
},
{
"id": "linq_message_delivered",
"name": "Linq Message Delivered",
"description": "Trigger workflow when a message is delivered"
},
{
"id": "linq_message_failed",
"name": "Linq Message Failed",
"description": "Trigger workflow when a message fails to deliver"
},
{
"id": "linq_message_read",
"name": "Linq Message Read",
"description": "Trigger workflow when a message is read"
},
{
"id": "linq_reaction_added",
"name": "Linq Reaction Added",
"description": "Trigger workflow when a reaction is added to a message"
},
{
"id": "linq_webhook",
"name": "Linq Webhook (All Events)",
"description": "Trigger on any Linq webhook event (messages, reactions, chats, and more)"
}
],
"triggerCount": 6,
"authType": "api-key",
"category": "tools",
"integrationType": "communication",
@@ -15649,11 +15680,11 @@
"landingContent": {
"install": {
"heading": "Add Sim to your Slack workspace",
"intro": "Sim connects to Slack through Slack’s official OAuth flow. The “Add to Slack” button lives inside your Sim account (after sign-in) — connect from there and the Sim bot is installed in your Slack workspace. The steps below show exactly how to reach it.",
"intro": "Sim connects to Slack through Slack’s official OAuth flow. The “Add to Slack” button lives inside your Sim account (after sign-in). Connect from there and the Sim bot is installed in your Slack workspace. The steps below show exactly how to reach it.",
"steps": [
{
"title": "Create your free Sim account",
"body": "Sign up at sim.ai — no credit card required."
"body": "Sign up at sim.ai. No credit card required."
},
{
"title": "Add a Slack block",
@@ -15673,7 +15704,7 @@
"body": "Sim requests only the Slack permissions its actions and triggers need, and never shows private channel names or messages to people who are not members of those channels in Slack.",
"href": "/privacy"
},
"aiDisclaimer": "Sim agents use AI models to generate messages and responses sent to Slack. AI-generated content can be inaccurate or incomplete — review automated outputs before relying on them, especially for important communications."
"aiDisclaimer": "Sim agents use AI models to generate messages and responses sent to Slack. AI-generated content can be inaccurate or incomplete, so review automated outputs before relying on them, especially for important communications."
}
},
{
+266
View File
@@ -0,0 +1,266 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { NextResponse } from 'next/server'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { LINQ_API_BASE, linqHeaders } from '@/tools/linq/utils'
import { LINQ_ALL_WEBHOOK_EVENT_TYPES, LINQ_TRIGGER_TO_EVENT_TYPE } from '@/triggers/linq/utils'
const logger = createLogger('WebhookProvider:Linq')
/** Max clock skew tolerated between the webhook timestamp and now (seconds). */
const MAX_TIMESTAMP_SKEW_SECONDS = 5 * 60
/**
* Verify a Linq webhook signature using the Standard Webhooks scheme.
* Linq signs `${webhook-id}.${webhook-timestamp}.${rawBody}` with HMAC-SHA256 using
* the base64-decoded `whsec_...` signing secret, and delivers the result as one or
* more space-separated `v1,<base64>` signatures in the `webhook-signature` header.
*/
function verifyLinqSignature(
secret: string,
msgId: string,
timestamp: string,
signatures: string,
rawBody: string
): boolean {
try {
const ts = Number.parseInt(timestamp, 10)
const now = Math.floor(Date.now() / 1000)
if (Number.isNaN(ts) || Math.abs(now - ts) > MAX_TIMESTAMP_SKEW_SECONDS) {
return false
}
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const toSign = `${msgId}.${timestamp}.${rawBody}`
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
for (const versionedSig of signatures.split(' ')) {
const parts = versionedSig.split(',')
if (parts.length !== 2) continue
if (safeCompare(parts[1], expectedSignature)) {
return true
}
}
return false
} catch (error) {
logger.error('Error verifying Linq webhook signature:', error)
return false
}
}
/** Parse a comma/whitespace-separated list of phone numbers into a clean array. */
function parsePhoneNumbers(value: unknown): string[] {
if (typeof value !== 'string') return []
return value
.split(/[\n,]/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
export const linqHandler: WebhookProviderHandler = {
async verifyAuth({
request,
rawBody,
requestId,
providerConfig,
}: AuthContext): Promise<NextResponse | null> {
const signingSecret = providerConfig.signingSecret as string | undefined
if (!signingSecret?.trim()) {
logger.warn(`[${requestId}] Linq webhook missing signing secret in provider configuration`)
return new NextResponse('Unauthorized - Linq signing secret is required', { status: 401 })
}
const webhookId = request.headers.get('webhook-id')
const webhookTimestamp = request.headers.get('webhook-timestamp')
const webhookSignature = request.headers.get('webhook-signature')
if (!webhookId || !webhookTimestamp || !webhookSignature) {
logger.warn(`[${requestId}] Linq webhook missing Standard Webhooks signature headers`)
return new NextResponse('Unauthorized - Missing Linq signature headers', { status: 401 })
}
if (
!verifyLinqSignature(signingSecret, webhookId, webhookTimestamp, webhookSignature, rawBody)
) {
logger.warn(`[${requestId}] Linq webhook signature verification failed`)
return new NextResponse('Unauthorized - Invalid Linq signature', { status: 401 })
}
return null
},
matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'linq_webhook') {
return true
}
const expectedType = LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]
if (!expectedType) {
logger.debug(`[${requestId}] Unknown Linq triggerId ${triggerId}, skipping.`)
return false
}
const actualType = (body as Record<string, unknown>)?.event_type as string | undefined
if (actualType !== expectedType) {
logger.debug(
`[${requestId}] Linq event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const payload = body as Record<string, unknown>
return {
input: {
eventType: payload.event_type ?? null,
eventId: payload.event_id ?? null,
createdAt: payload.created_at ?? null,
webhookVersion: payload.webhook_version ?? null,
data: payload.data ?? null,
},
}
},
extractIdempotencyId(body: unknown): string | null {
const eventId = (body as Record<string, unknown>)?.event_id
return typeof eventId === 'string' && eventId.length > 0 ? eventId : null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
const providerConfig = getProviderConfig(webhook)
const apiKey = providerConfig.triggerApiKey as string | undefined
const triggerId = providerConfig.triggerId as string | undefined
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Linq webhook creation.`, {
webhookId: webhook.id,
})
throw new Error(
'Linq API Key is required. Please provide your Linq API Key in the trigger configuration.'
)
}
const events =
triggerId === 'linq_webhook'
? LINQ_ALL_WEBHOOK_EVENT_TYPES
: triggerId && LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]
? [LINQ_TRIGGER_TO_EVENT_TYPE[triggerId]]
: null
if (!events?.length) {
throw new Error(`Unknown or unsupported Linq trigger type: ${triggerId ?? '(missing)'}`)
}
const phoneNumbers = parsePhoneNumbers(providerConfig.triggerPhoneNumbers)
const requestBody: Record<string, unknown> = {
target_url: getNotificationUrl(webhook),
subscribed_events: events,
}
if (phoneNumbers.length > 0) {
requestBody.phone_numbers = phoneNumbers
}
logger.info(`[${requestId}] Creating Linq webhook subscription`, {
triggerId,
events,
webhookId: webhook.id,
})
const response = await fetch(`${LINQ_API_BASE}/webhook-subscriptions`, {
method: 'POST',
headers: linqHeaders(apiKey),
body: JSON.stringify(requestBody),
})
const responseBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
if (!response.ok) {
const errorMessage =
((responseBody.error as Record<string, unknown>)?.message as string) ||
(responseBody.message as string) ||
'Unknown Linq API error'
logger.error(
`[${requestId}] Failed to create Linq webhook subscription for webhook ${webhook.id}. Status: ${response.status}`,
{ message: errorMessage }
)
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Linq API Key. Please verify your API Key is correct.')
}
throw new Error(`Linq error: ${errorMessage}`)
}
const externalId = responseBody.id
const signingSecret = responseBody.signing_secret
if (typeof externalId !== 'string' || !externalId.trim()) {
throw new Error('Linq webhook was created but the API response did not include a webhook id.')
}
if (typeof signingSecret !== 'string' || !signingSecret.trim()) {
throw new Error(
'Linq webhook was created but the API response did not include a signing secret.'
)
}
logger.info(`[${requestId}] Successfully created Linq webhook subscription ${externalId}.`)
return {
providerConfigUpdates: {
externalId,
signingSecret,
},
}
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const { webhook, requestId } = ctx
try {
const config = getProviderConfig(webhook)
const apiKey = config.triggerApiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey || !externalId) {
logger.warn(
`[${requestId}] Missing apiKey or externalId for Linq webhook deletion ${webhook.id}, skipping cleanup`
)
if (ctx.strict) throw new Error('Missing Linq webhook deletion credentials')
return
}
const response = await fetch(`${LINQ_API_BASE}/webhook-subscriptions/${externalId}`, {
method: 'DELETE',
headers: linqHeaders(apiKey),
})
if (!response.ok && response.status !== 404) {
logger.warn(
`[${requestId}] Failed to delete Linq webhook subscription (non-fatal): ${response.status}`
)
if (ctx.strict) {
throw new Error(`Failed to delete Linq webhook subscription: ${response.status}`)
}
} else {
logger.info(`[${requestId}] Successfully deleted Linq webhook subscription ${externalId}`)
}
} catch (error) {
logger.warn(`[${requestId}] Error deleting Linq webhook subscription (non-fatal)`, error)
if (ctx.strict) throw error
}
},
}
@@ -28,6 +28,7 @@ import { jiraHandler } from '@/lib/webhooks/providers/jira'
import { jsmHandler } from '@/lib/webhooks/providers/jsm'
import { lemlistHandler } from '@/lib/webhooks/providers/lemlist'
import { linearHandler } from '@/lib/webhooks/providers/linear'
import { linqHandler } from '@/lib/webhooks/providers/linq'
import { loopsHandler } from '@/lib/webhooks/providers/loops'
import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams'
import { mondayHandler } from '@/lib/webhooks/providers/monday'
@@ -88,6 +89,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
jsm: jsmHandler,
lemlist: lemlistHandler,
linear: linearHandler,
linq: linqHandler,
loops: loopsHandler,
monday: mondayHandler,
resend: resendHandler,
+3 -3
View File
@@ -44,7 +44,7 @@ export const linqCheckImessageTool: ToolConfig<
},
transformResponse: async (response): Promise<LinqCapabilityCheckResult> => {
const data = await response.json()
const data = await response.json().catch(() => null)
if (!response.ok) {
return {
@@ -57,8 +57,8 @@ export const linqCheckImessageTool: ToolConfig<
return {
success: true,
output: {
address: data.address ?? '',
available: data.available ?? false,
address: data?.address ?? '',
available: data?.available ?? false,
},
}
},
+4 -4
View File
@@ -19,7 +19,7 @@ export const linqCheckRcsTool: ToolConfig<LinqCapabilityCheckParams, LinqCapabil
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Phone number (E.164 format) or email address to check',
description: 'Phone number (E.164 format) to check',
},
from: {
type: 'string',
@@ -41,7 +41,7 @@ export const linqCheckRcsTool: ToolConfig<LinqCapabilityCheckParams, LinqCapabil
},
transformResponse: async (response): Promise<LinqCapabilityCheckResult> => {
const data = await response.json()
const data = await response.json().catch(() => null)
if (!response.ok) {
return {
@@ -54,8 +54,8 @@ export const linqCheckRcsTool: ToolConfig<LinqCapabilityCheckParams, LinqCapabil
return {
success: true,
output: {
address: data.address ?? '',
available: data.available ?? false,
address: data?.address ?? '',
available: data?.available ?? false,
},
}
},
+28 -14
View File
@@ -48,7 +48,9 @@ export const linqEditMessageTool: ToolConfig<LinqEditMessageParams, LinqMessageR
},
transformResponse: async (response): Promise<LinqMessageResult> => {
const data = await response.json()
// Linq returns 200 with the updated Message, or 204 No Content when the edit
// is accepted for an already-deleted message — guard the empty-body case.
const data = await response.json().catch(() => null)
if (!response.ok) {
return {
@@ -58,6 +60,7 @@ export const linqEditMessageTool: ToolConfig<LinqEditMessageParams, LinqMessageR
id: '',
chatId: '',
isFromMe: null,
deliveryStatus: null,
isDelivered: null,
isRead: null,
service: null,
@@ -70,20 +73,22 @@ export const linqEditMessageTool: ToolConfig<LinqEditMessageParams, LinqMessageR
}
}
const message = data ?? {}
return {
success: true,
output: {
id: data.id ?? '',
chatId: data.chat_id ?? '',
isFromMe: data.is_from_me ?? null,
isDelivered: data.is_delivered ?? null,
isRead: data.is_read ?? null,
service: data.service ?? null,
createdAt: data.created_at ?? null,
updatedAt: data.updated_at ?? null,
sentAt: data.sent_at ?? null,
parts: data.parts ?? [],
message: data,
id: message.id ?? '',
chatId: message.chat_id ?? '',
isFromMe: message.is_from_me ?? null,
deliveryStatus: message.delivery_status ?? null,
isDelivered: message.is_delivered ?? null,
isRead: message.is_read ?? null,
service: message.service ?? null,
createdAt: message.created_at ?? null,
updatedAt: message.updated_at ?? null,
sentAt: message.sent_at ?? null,
parts: message.parts ?? [],
message,
},
}
},
@@ -96,12 +101,21 @@ export const linqEditMessageTool: ToolConfig<LinqEditMessageParams, LinqMessageR
description: 'Whether the message was sent by you',
optional: true,
},
deliveryStatus: {
type: 'string',
description: 'Delivery status (pending, queued, sent, delivered, received, read, failed)',
optional: true,
},
isDelivered: {
type: 'boolean',
description: 'Whether the message was delivered',
description: 'Whether the message was delivered (deprecated; use deliveryStatus)',
optional: true,
},
isRead: {
type: 'boolean',
description: 'Whether the message was read (deprecated; use deliveryStatus)',
optional: true,
},
isRead: { type: 'boolean', description: 'Whether the message was read', optional: true },
service: {
type: 'string',
description: 'Delivery service (iMessage, SMS, RCS)',
+13 -2
View File
@@ -40,6 +40,7 @@ export const linqGetMessageTool: ToolConfig<LinqGetMessageParams, LinqMessageRes
id: '',
chatId: '',
isFromMe: null,
deliveryStatus: null,
isDelivered: null,
isRead: null,
service: null,
@@ -58,6 +59,7 @@ export const linqGetMessageTool: ToolConfig<LinqGetMessageParams, LinqMessageRes
id: data.id ?? '',
chatId: data.chat_id ?? '',
isFromMe: data.is_from_me ?? null,
deliveryStatus: data.delivery_status ?? null,
isDelivered: data.is_delivered ?? null,
isRead: data.is_read ?? null,
service: data.service ?? null,
@@ -78,12 +80,21 @@ export const linqGetMessageTool: ToolConfig<LinqGetMessageParams, LinqMessageRes
description: 'Whether the message was sent by you',
optional: true,
},
deliveryStatus: {
type: 'string',
description: 'Delivery status (pending, queued, sent, delivered, received, read, failed)',
optional: true,
},
isDelivered: {
type: 'boolean',
description: 'Whether the message was delivered',
description: 'Whether the message was delivered (deprecated; use deliveryStatus)',
optional: true,
},
isRead: {
type: 'boolean',
description: 'Whether the message was read (deprecated; use deliveryStatus)',
optional: true,
},
isRead: { type: 'boolean', description: 'Whether the message was read', optional: true },
service: {
type: 'string',
description: 'Delivery service (iMessage, SMS, RCS)',
+15 -4
View File
@@ -31,7 +31,7 @@ export const linqListPhoneNumbersTool: ToolConfig<
},
transformResponse: async (response): Promise<LinqListPhoneNumbersResult> => {
const data = await response.json()
const data = await response.json().catch(() => null)
if (!response.ok) {
return {
@@ -44,10 +44,12 @@ export const linqListPhoneNumbersTool: ToolConfig<
return {
success: true,
output: {
phoneNumbers: (data.phone_numbers ?? []).map((num: Record<string, unknown>) => ({
phoneNumbers: (data?.phone_numbers ?? []).map((num: Record<string, unknown>) => ({
id: (num.id as string) ?? '',
phoneNumber: (num.phone_number as string) ?? '',
healthStatus: (num.health_status as LinqHealthStatus | undefined) ?? null,
forwardingNumber: (num.forwarding_number as string | null) ?? null,
healthStatus:
((num.reputation ?? num.health_status) as LinqHealthStatus | undefined) ?? null,
})),
},
}
@@ -62,7 +64,16 @@ export const linqListPhoneNumbersTool: ToolConfig<
properties: {
id: { type: 'string', description: 'Phone number ID' },
phoneNumber: { type: 'string', description: 'Phone number in E.164 format' },
healthStatus: { type: 'json', description: 'Line health status (status, doc_url)' },
forwardingNumber: {
type: 'string',
description: 'Forwarding number in E.164 format, or null',
nullable: true,
},
healthStatus: {
type: 'json',
description: 'Line reputation/health status (status, doc_url)',
nullable: true,
},
},
},
},
@@ -63,10 +63,22 @@ export const linqListWebhookSubscriptionsTool: ToolConfig<
id: { type: 'string', description: 'Subscription ID' },
targetUrl: { type: 'string', description: 'Endpoint that receives events' },
subscribedEvents: { type: 'json', description: 'Subscribed event types' },
phoneNumbers: { type: 'json', description: 'Filtered phone numbers (null = all)' },
phoneNumbers: {
type: 'json',
description: 'Filtered phone numbers (null = all)',
nullable: true,
},
isActive: { type: 'boolean', description: 'Whether the subscription is active' },
createdAt: { type: 'string', description: 'ISO 8601 creation timestamp' },
updatedAt: { type: 'string', description: 'ISO 8601 update timestamp' },
createdAt: {
type: 'string',
description: 'ISO 8601 creation timestamp',
nullable: true,
},
updatedAt: {
type: 'string',
description: 'ISO 8601 update timestamp',
nullable: true,
},
},
},
},
+2 -1
View File
@@ -5,7 +5,8 @@ import type { ToolConfig } from '@/tools/types'
export const linqMarkChatReadTool: ToolConfig<LinqChatActionParams, LinqSuccessResult> = {
id: 'linq_mark_chat_read',
name: 'Mark Chat as Read',
description: 'Mark all messages in a chat as read',
description:
'Mark messages in a chat as read (only applies to 1:1 iMessage/RCS; no effect on group chats)',
version: '1.0.0',
params: {
+3 -2
View File
@@ -123,7 +123,8 @@ export const linqSendMessageTool: ToolConfig<LinqSendMessageParams, LinqSendMess
messageId: message.id ?? '',
deliveryStatus: message.delivery_status ?? null,
sentAt: message.sent_at ?? null,
service: message.service ?? null,
service:
message.service ?? message.from_handle?.service ?? message.preferred_service ?? null,
message,
},
}
@@ -134,7 +135,7 @@ export const linqSendMessageTool: ToolConfig<LinqSendMessageParams, LinqSendMess
messageId: { type: 'string', description: 'ID of the sent message' },
deliveryStatus: {
type: 'string',
description: 'Delivery status (pending, queued, sent, delivered, failed)',
description: 'Delivery status (pending, queued, sent, delivered, received, read, failed)',
optional: true,
},
sentAt: {
+2
View File
@@ -204,6 +204,7 @@ export interface LinqMessageResult extends ToolResponse {
id: string
chatId: string
isFromMe: boolean | null
deliveryStatus: string | null
isDelivered: boolean | null
isRead: boolean | null
service: string | null
@@ -280,6 +281,7 @@ export interface LinqListPhoneNumbersResult extends ToolResponse {
phoneNumbers: Array<{
id: string
phoneNumber: string
forwardingNumber: string | null
healthStatus: LinqHealthStatus | null
}>
}
+6
View File
@@ -0,0 +1,6 @@
export { linqMessageDeliveredTrigger } from './message_delivered'
export { linqMessageFailedTrigger } from './message_failed'
export { linqMessageReadTrigger } from './message_read'
export { linqMessageReceivedTrigger } from './message_received'
export { linqReactionAddedTrigger } from './reaction_added'
export { linqWebhookTrigger } from './webhook'
@@ -0,0 +1,38 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Linq Message Delivered Trigger
* Fires when a sent message is confirmed delivered to the recipient.
*/
export const linqMessageDeliveredTrigger: TriggerConfig = {
id: 'linq_message_delivered',
name: 'Linq Message Delivered',
provider: 'linq',
description: 'Trigger workflow when a message is delivered',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_message_delivered',
triggerOptions: linqTriggerOptions,
setupInstructions: linqSetupInstructions('message.delivered'),
extraFields: buildLinqExtraFields('linq_message_delivered'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
+38
View File
@@ -0,0 +1,38 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Linq Message Failed Trigger
* Fires when a sent message fails to deliver.
*/
export const linqMessageFailedTrigger: TriggerConfig = {
id: 'linq_message_failed',
name: 'Linq Message Failed',
provider: 'linq',
description: 'Trigger workflow when a message fails to deliver',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_message_failed',
triggerOptions: linqTriggerOptions,
setupInstructions: linqSetupInstructions('message.failed'),
extraFields: buildLinqExtraFields('linq_message_failed'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
+38
View File
@@ -0,0 +1,38 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Linq Message Read Trigger
* Fires when a recipient reads a sent message (1:1 iMessage/RCS).
*/
export const linqMessageReadTrigger: TriggerConfig = {
id: 'linq_message_read',
name: 'Linq Message Read',
provider: 'linq',
description: 'Trigger workflow when a message is read',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_message_read',
triggerOptions: linqTriggerOptions,
setupInstructions: linqSetupInstructions('message.read'),
extraFields: buildLinqExtraFields('linq_message_read'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
@@ -0,0 +1,39 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Linq Message Received Trigger
* Fires when an inbound iMessage, SMS, or RCS message arrives.
*/
export const linqMessageReceivedTrigger: TriggerConfig = {
id: 'linq_message_received',
name: 'Linq Message Received',
provider: 'linq',
description: 'Trigger workflow when an inbound message is received',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_message_received',
triggerOptions: linqTriggerOptions,
includeDropdown: true,
setupInstructions: linqSetupInstructions('message.received'),
extraFields: buildLinqExtraFields('linq_message_received'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
+38
View File
@@ -0,0 +1,38 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Linq Reaction Added Trigger
* Fires when a tapback or custom reaction is added to a message.
*/
export const linqReactionAddedTrigger: TriggerConfig = {
id: 'linq_reaction_added',
name: 'Linq Reaction Added',
provider: 'linq',
description: 'Trigger workflow when a reaction is added to a message',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_reaction_added',
triggerOptions: linqTriggerOptions,
setupInstructions: linqSetupInstructions('reaction.added'),
extraFields: buildLinqExtraFields('linq_reaction_added'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
+143
View File
@@ -0,0 +1,143 @@
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
/**
* Maps Sim Linq trigger IDs to a single Linq webhook event type.
* Kept in sync with subscription registration in the `linq` webhook provider.
*/
export const LINQ_TRIGGER_TO_EVENT_TYPE: Record<string, string> = {
linq_message_received: 'message.received',
linq_message_delivered: 'message.delivered',
linq_message_failed: 'message.failed',
linq_message_read: 'message.read',
linq_reaction_added: 'reaction.added',
}
/**
* Every Linq webhook event type, registered for the catch-all `linq_webhook` trigger.
* Mirrors the Linq OpenAPI `WebhookEventType` enum verbatim (all 27 values).
*/
export const LINQ_ALL_WEBHOOK_EVENT_TYPES: string[] = [
'message.sent',
'message.received',
'message.read',
'message.delivered',
'message.failed',
'message.edited',
'reaction.added',
'reaction.removed',
'participant.added',
'participant.removed',
'chat.created',
'chat.group_name_updated',
'chat.group_icon_updated',
'chat.group_name_update_failed',
'chat.group_icon_update_failed',
'chat.typing_indicator.started',
'chat.typing_indicator.stopped',
'phone_number.status_updated',
'call.initiated',
'call.ringing',
'call.answered',
'call.ended',
'call.failed',
'call.declined',
'call.no_answer',
'location.sharing.started',
'location.sharing.stopped',
]
/** Shared trigger dropdown options for all Linq triggers. */
export const linqTriggerOptions = [
{ label: 'Message Received', id: 'linq_message_received' },
{ label: 'Message Delivered', id: 'linq_message_delivered' },
{ label: 'Message Failed', id: 'linq_message_failed' },
{ label: 'Message Read', id: 'linq_message_read' },
{ label: 'Reaction Added', id: 'linq_reaction_added' },
{ label: 'Webhook (All Events)', id: 'linq_webhook' },
]
/**
* Generates setup instructions for Linq webhooks.
* The subscription is created and deleted automatically via the Linq API.
*/
export function linqSetupInstructions(eventType: string): string {
const instructions = [
'Enter your Linq API Key above.',
'You can find your API key in the <a href="https://docs.linqapp.com/" target="_blank" rel="noopener noreferrer">Linq partner dashboard</a>.',
'Optionally restrict delivery to specific phone numbers (E.164, comma-separated). Leave empty to receive events from all numbers.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook subscription in Linq for <strong>${eventType}</strong>.`,
'The subscription is automatically deleted when you remove this trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}
/**
* Builds Linq-specific extra fields for the trigger UI.
* Includes the required API key and an optional phone-number filter.
* Use with the generic `buildTriggerSubBlocks` from `@/triggers`.
*/
export function buildLinqExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'triggerApiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Linq API key',
description: 'Required to create the webhook subscription in Linq.',
password: true,
paramVisibility: 'user-only',
required: true,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
{
id: 'triggerPhoneNumbers',
title: 'Phone Numbers (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all numbers (e.g. +15551234567, +15557654321)',
description: 'Comma-separated E.164 numbers to restrict which numbers deliver events.',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
/**
* Outputs exposed by every Linq trigger.
*
* Only the delivery-envelope fields are documented in the Linq OpenAPI spec; the
* per-event `data` shape is not enumerated, so it is surfaced as a JSON passthrough
* rather than fabricating typed sub-fields.
*/
export function buildLinqOutputs(): Record<string, TriggerOutput> {
return {
eventType: {
type: 'string',
description: 'Event type (e.g. message.received, message.delivered, reaction.added)',
},
eventId: {
type: 'string',
description: 'Unique event identifier used for deduplication',
},
createdAt: {
type: 'string',
description: 'ISO 8601 timestamp of when the event occurred',
},
webhookVersion: {
type: 'string',
description: 'Payload schema version of the delivered event',
},
data: {
type: 'json',
description:
'Full event payload (shape varies by event type — message, reaction, chat, etc.)',
},
}
}
+40
View File
@@ -0,0 +1,40 @@
import { LinqIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
buildLinqExtraFields,
buildLinqOutputs,
linqSetupInstructions,
linqTriggerOptions,
} from '@/triggers/linq/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
* Generic Linq Webhook Trigger
* Subscribes to every Linq webhook event type (messages, reactions, participants,
* chats, typing, phone number status, location sharing). Use the <code>data</code>
* output for the full payload, which varies by <code>eventType</code>.
*/
export const linqWebhookTrigger: TriggerConfig = {
id: 'linq_webhook',
name: 'Linq Webhook (All Events)',
provider: 'linq',
description: 'Trigger on any Linq webhook event (messages, reactions, chats, and more)',
version: '1.0.0',
icon: LinqIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'linq_webhook',
triggerOptions: linqTriggerOptions,
setupInstructions: linqSetupInstructions('All Events'),
extraFields: buildLinqExtraFields('linq_webhook'),
}),
outputs: buildLinqOutputs(),
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}
+14
View File
@@ -267,6 +267,14 @@ import {
linearWebhookTrigger,
linearWebhookV2Trigger,
} from '@/triggers/linear'
import {
linqMessageDeliveredTrigger,
linqMessageFailedTrigger,
linqMessageReadTrigger,
linqMessageReceivedTrigger,
linqReactionAddedTrigger,
linqWebhookTrigger,
} from '@/triggers/linq'
import {
loopsCampaignEmailSentTrigger,
loopsEmailClickedTrigger,
@@ -598,6 +606,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
linear_project_update_created_v2: linearProjectUpdateCreatedV2Trigger,
linear_customer_request_created_v2: linearCustomerRequestCreatedV2Trigger,
linear_customer_request_updated_v2: linearCustomerRequestUpdatedV2Trigger,
linq_message_received: linqMessageReceivedTrigger,
linq_message_delivered: linqMessageDeliveredTrigger,
linq_message_failed: linqMessageFailedTrigger,
linq_message_read: linqMessageReadTrigger,
linq_reaction_added: linqReactionAddedTrigger,
linq_webhook: linqWebhookTrigger,
monday_item_created: mondayItemCreatedTrigger,
monday_column_changed: mondayColumnChangedTrigger,
monday_status_changed: mondayStatusChangedTrigger,