The Linq gateway provides a Python client for Linq’s Partner API V3, enabling iMessage, RCS, and SMS messaging with native Apple features.
Overview
Linq is the primary transport layer for CareSupport. It supports:
- iMessage (blue bubble) — typing indicators, read receipts, tapbacks, screen effects, voice memos
- RCS (Rich Communication Services) — delivery confirmation, read receipts
- SMS (fallback) — universal compatibility
The API automatically handles fallback: iMessage → RCS → SMS based on recipient capability.
Module: runtime/scripts/linq_gateway.py
Configuration
Linq credentials are loaded from runtime/config.py:
from config import linq
linq.api_token # Bearer token for Linq API
linq.phone_number # CareSupport's Linq Blue number (E.164)
linq.base_url # https://api.linqapp.com/api/partner/v3
linq.webhook_signing_secret # HMAC secret for webhook verification
Configuration file: runtime/scripts/linq_config.json
{
"linq_api_token": "...",
"linq_phone": "+16517037981",
"base_url": "https://api.linqapp.com/api/partner/v3",
"webhook_signing_secret": "..."
}
Location: runtime/config.py:124
Chat Operations
Create Chat
async def create_chat(
to_phone: str,
initial_message: str,
from_phone: str = "",
preferred_service: str | None = None,
effect: dict | None = None
) -> dict
Recipient phone in E.164 format (e.g., +16517037981)
Text of the first message
Sender phone (defaults to configured Linq Blue number)
Force a specific service: "iMessage", "RCS", or "SMS". Omit for automatic fallback.
iMessage screen effect (e.g., {"type": "screen", "name": "confetti"})
Returns:
Whether the chat was created successfully
Persistent UUID for this conversation (use this for all subsequent messages)
UUID of the initial message
Actual service used: "iMessage", "RCS", or "SMS"
Location: runtime/scripts/linq_gateway.py:72
Example:
result = await create_chat(
to_phone="+16517037981",
initial_message="Welcome to CareSupport!",
preferred_service="iMessage",
effect={"type": "screen", "name": "balloons"}
)
if result["success"]:
chat_id = result["chat_id"]
print(f"Chat created: {chat_id} via {result['service']}")
Send Message
async def send_message(
chat_id: str,
text: str,
preferred_service: str | None = None,
effect: dict | None = None,
reply_to_message_id: str | None = None,
media_url: str | None = None,
attachment_id: str | None = None
) -> dict
UUID of the chat (from create_chat or webhook)
Force a specific service (usually omit for auto-fallback)
iMessage screen effect: {"type": "screen", "name": "fireworks" | "confetti" | "balloons" | "lasers"}
UUID of message to reply to (creates iMessage thread)
Public URL of media to attach (up to 10MB)
Pre-uploaded attachment ID (for files >10MB, up to 100MB)
Returns:
Whether the message was sent
"pending", "sent", "delivered", or "failed"
Location: runtime/scripts/linq_gateway.py:130
Example:
result = await send_message(
chat_id="550e8400-e29b-41d4-a716-446655440000",
text="Liban will pick up Degitu at 8am tomorrow.",
effect={"type": "screen", "name": "confetti"}
)
List Chats
async def list_chats(
from_phone: str = "",
limit: int = 20,
cursor: str = ""
) -> dict
Filter by sender phone (defaults to configured Linq number)
Number of chats to return (max 100)
Pagination cursor from previous response
Returns:
Whether the request succeeded
Cursor for next page (empty if no more results)
Location: runtime/scripts/linq_gateway.py:108
iMessage Features
Typing Indicators
async def start_typing(chat_id: str) -> dict
Shows typing indicator (1:1 iMessage chats only). Automatically stops after ~10 seconds or when a message is sent.
Location: runtime/scripts/linq_gateway.py:214
async def stop_typing(chat_id: str) -> dict
Manually stops typing indicator.
Location: runtime/scripts/linq_gateway.py:220
Read Receipts
async def mark_as_read(chat_id: str) -> dict
Marks all messages in a chat as read. Sends read receipt to sender (iMessage only).
Location: runtime/scripts/linq_gateway.py:228
Reactions (Tapbacks)
async def add_reaction(
message_id: str,
reaction_type: str = "love",
custom_emoji: str = "",
part_index: int = 0
) -> dict
UUID of the message to react to
Reaction type: "love" ❤️, "like" 👍, "dislike" 👎, "laugh" 😂, "emphasize" ‼️, "question" ❓, or "custom"
Custom emoji for reaction_type="custom" (e.g., "🎉")
Message part to react to (for multi-part messages)
Location: runtime/scripts/linq_gateway.py:189
async def remove_reaction(
message_id: str,
reaction_type: str = "love"
) -> dict
Removes a previously added reaction.
Location: runtime/scripts/linq_gateway.py:204
Example:
# React with ❤️ to a message
await add_reaction(
message_id="550e8400-e29b-41d4-a716-446655440000",
reaction_type="love"
)
# Custom emoji reaction
await add_reaction(
message_id="550e8400-e29b-41d4-a716-446655440000",
reaction_type="custom",
custom_emoji="🎉"
)
Voice Memos
async def send_voice_memo(
chat_id: str,
voice_memo_url: str,
from_phone: str = ""
) -> dict
Public URL of audio file (MP3, M4A, AAC, CAF, WAV, AIFF, AMR; max 10MB)
Sender phone (defaults to configured Linq number)
iMessage displays voice memos with native playback UI (waveform, scrubber, play/pause).
Location: runtime/scripts/linq_gateway.py:243
async def share_contact_card(chat_id: str) -> dict
Pushes CareSupport’s branded contact card to the recipient (iMessage-exclusive). Appears as a rich card with logo and “Add to Contacts” button.
Location: runtime/scripts/linq_gateway.py:236
Group Chat
Add Participant
async def add_participant(chat_id: str, handle: str) -> dict
Group chat UUID (must have 3+ existing participants)
Phone number or email to add
Location: runtime/scripts/linq_gateway.py:256
Remove Participant
async def remove_participant(chat_id: str, handle: str) -> dict
Removes a participant from a group chat.
Location: runtime/scripts/linq_gateway.py:262
Attachments
Pre-Upload (Large Files)
async def pre_upload_attachment(
filename: str,
content_type: str,
size_bytes: int
) -> dict
Original filename (e.g., "lab_results.pdf")
MIME type (e.g., "application/pdf")
File size in bytes (max 100MB)
Returns:
Whether pre-upload was successful
Presigned URL to PUT the file to (expires in 1 hour)
ID to reference in send_message(attachment_id=...)
Location: runtime/scripts/linq_gateway.py:270
Example:
# 1. Pre-upload to get attachment_id
result = await pre_upload_attachment(
filename="care_plan.pdf",
content_type="application/pdf",
size_bytes=5242880 # 5MB
)
if result["success"]:
# 2. PUT file to upload_url (use requests, httpx, etc.)
import httpx
async with httpx.AsyncClient() as client:
await client.put(result["upload_url"], content=pdf_bytes)
# 3. Send message with attachment_id
await send_message(
chat_id=chat_id,
text="Here's your care plan.",
attachment_id=result["attachment_id"]
)
Webhooks
List Subscriptions
async def list_webhook_subscriptions() -> dict
Returns:
Whether the request succeeded
List of active webhook subscriptions
Location: runtime/scripts/linq_gateway.py:295
Create Subscription
async def create_webhook_subscription(
target_url: str,
events: list[str],
version: str = "2026-02-03"
) -> dict
Public HTTPS URL to receive webhook events
Event types to subscribe to:
"message.created" — Inbound message received
"message.updated" — Delivery status changed
"reaction.created" — Tapback received
"reaction.removed" — Tapback removed
"chat.created" — New chat initiated
version
string
default:"2026-02-03"
API version for webhook payload schema
Returns:
Whether the subscription was created
HMAC-SHA256 secret for verifying webhook authenticity (store securely!)
Location: runtime/scripts/linq_gateway.py:303
Example:
result = await create_webhook_subscription(
target_url="https://caresupport.app/webhooks/linq",
events=["message.created", "reaction.created"]
)
if result["success"]:
# CRITICAL: Store signing_secret in config
signing_secret = result["signing_secret"]
print(f"Subscription ID: {result['subscription_id']}")
Delete Subscription
async def delete_webhook_subscription(subscription_id: str) -> dict
Deletes a webhook subscription.
Location: runtime/scripts/linq_gateway.py:319
Verify Webhook Signature
def verify_webhook_signature(
payload: bytes,
timestamp: str,
signature: str
) -> bool
Raw request body (do NOT parse first)
X-Linq-Timestamp header value
X-Linq-Signature header value (hex-encoded HMAC)
Returns: True if signature is valid, False if forged
Location: runtime/scripts/linq_gateway.py:327
Example (Flask webhook receiver):
from flask import Flask, request
from linq_gateway import verify_webhook_signature
app = Flask(__name__)
@app.route("/webhooks/linq", methods=["POST"])
def handle_webhook():
payload = request.get_data()
timestamp = request.headers.get("X-Linq-Timestamp")
signature = request.headers.get("X-Linq-Signature")
if not verify_webhook_signature(payload, timestamp, signature):
return "Invalid signature", 401
event = request.json
if event["event_type"] == "message.created":
# Process inbound message
chat_id = event["data"]["chat_id"]
text = event["data"]["message"]["parts"][0]["value"]
# ...
return "", 200
Phone Numbers
async def list_phone_numbers() -> dict
Lists all phone numbers assigned to this Linq account.
Returns:
Whether the request succeeded
List of phone number objects
Location: runtime/scripts/linq_gateway.py:285
CLI Usage
# Create a new chat
python runtime/scripts/linq_gateway.py create \
--to "+16517037981" \
--body "Welcome to CareSupport!" \
--service iMessage
# Send to existing chat
python runtime/scripts/linq_gateway.py send \
--chat-id "550e8400-e29b-41d4-a716-446655440000" \
--body "Liban will drive tomorrow at 8am."
# List all chats
python runtime/scripts/linq_gateway.py list-chats
# List phone numbers
python runtime/scripts/linq_gateway.py phones
# Start typing indicator
python runtime/scripts/linq_gateway.py typing \
--chat-id "550e8400-e29b-41d4-a716-446655440000"
# React to a message
python runtime/scripts/linq_gateway.py react \
--message-id "550e8400-e29b-41d4-a716-446655440000" \
--type love
# List webhook subscriptions
python runtime/scripts/linq_gateway.py webhooks
Integration with SMS Handler
The polling loop (poll_inbound.py) calls Linq API to check for new messages, then passes them to the SMS handler:
from linq_gateway import list_chats, get_messages
from sms_handler import handle_sms, resolve_chat_id
# Poll for new messages
chats = await list_chats(limit=50)
for chat in chats["chats"]:
messages = await get_messages(chat["id"], limit=10)
for msg in messages["messages"]:
if msg["direction"] == "inbound" and not_yet_processed(msg["id"]):
# Resolve chat_id to member
member = resolve_chat_id(chat["id"])
if member:
# Pass to SMS handler
result = await handle_sms(
from_phone=member["phone"],
body=msg["parts"][0]["value"],
service=msg["service"] # "iMessage", "RCS", or "SMS"
)
See: runtime/scripts/poll_inbound.py