Skip to content
Get started

API Reference

Libraries

npm install @linqapp/sdk
pip install linq-python
go get -u 'github.com/linq-team/[email protected]'

API Overview

Chats

Create a new chat
POST/v3/chats
List all chats
GET/v3/chats
Get a chat by ID
GET/v3/chats/{chatId}
Update a chat
PUT/v3/chats/{chatId}
Mark chat as read
POST/v3/chats/{chatId}/read
Leave a group chat
POST/v3/chats/{chatId}/leave
Share your contact card with a chat
POST/v3/chats/{chatId}/share_contact_card
Send a voice memo to a chat
POST/v3/chats/{chatId}/voicememo

ChatsParticipants

A Chat is a conversation thread with one or more participants.

To begin a chat, you must create a Chat with at least one recipient handle. Including multiple handles creates a group chat.

When creating a chat, the from field specifies which of your authorized phone numbers the message originates from. Your authentication token grants access to one or more phone numbers, but the from field determines the actual sender.

Handle Format:

  • Handles can be phone numbers or email addresses
  • Phone numbers MUST be in E.164 format (starting with +)
  • Phone format: +[country code][subscriber number]
  • Example phone: +12223334444 (US), +442071234567 (UK), +81312345678 (Japan)
  • Example email: [email protected]
  • No spaces, dashes, or parentheses in phone numbers
Add a participant to a chat
POST/v3/chats/{chatId}/participants
Remove a participant from a chat
DELETE/v3/chats/{chatId}/participants

ChatsTyping

A Chat is a conversation thread with one or more participants.

To begin a chat, you must create a Chat with at least one recipient handle. Including multiple handles creates a group chat.

When creating a chat, the from field specifies which of your authorized phone numbers the message originates from. Your authentication token grants access to one or more phone numbers, but the from field determines the actual sender.

Handle Format:

  • Handles can be phone numbers or email addresses
  • Phone numbers MUST be in E.164 format (starting with +)
  • Phone format: +[country code][subscriber number]
  • Example phone: +12223334444 (US), +442071234567 (UK), +81312345678 (Japan)
  • Example email: [email protected]
  • No spaces, dashes, or parentheses in phone numbers
Start typing indicator
POST/v3/chats/{chatId}/typing
Stop typing indicator
DELETE/v3/chats/{chatId}/typing

ChatsMessages

Messages are individual communications within a chat thread.

Messages can include text, media attachments, rich link previews, special effects (like confetti or fireworks), and reactions. All messages are associated with a specific chat and sent from a phone number you own.

Messages support delivery status tracking, read receipts, and editing capabilities.

Send a URL as a link part to deliver it with a rich preview card showing the page’s title, description, and image (when available). A link part must be the only part in the message — it cannot be combined with text or media parts. To send a URL without a preview card, include it in a text part instead.

Limitations:

  • A link part cannot be combined with other parts in the same message.
  • Maximum URL length: 2,048 characters.

Ephemeral Messages (Privacy Tier)

For regulated or sensitive conversations, opt in to the ephemeral messages tier by contacting your Linq support contact. When enabled, every message on the covered phone numbers is automatically given a fixed 24-hour retention window — after that window the platform permanently deletes the message from Linq storage. There is no per-message flag; ephemerality is applied automatically based on your configuration.

You can request it at two scopes:

ScopeEffect
Partner-wideEvery outbound and inbound message on every phone number under your account is retained for 24 hours, then deleted.
Per phone numberOnly the specified phone numbers have their messages auto-deleted. The rest follow the standard message-retention policy.

Behavioral differences vs the standard default:

AspectStandardEphemeral
RetentionRetained per the standard message-retention policyHard backstop: 24 hours from when the message is created
After expiryMessage stays retrievableMessage is permanently deleted — GET /v3/messages/{messageId} returns 404 and it no longer appears in GET /v3/chats/{chatId}/messages
Content on expiryN/AText, formatting, and attachment references are scrubbed; the message is gone, not blanked out
Cross-partner isolationEnforcedEnforced

How the 24-hour window works:

  • The window is fixed at 24 hours from message creation (created_at) and cannot be configured per message.
  • It mirrors the ephemeral attachments 1-day backstop, so a message and any media it carries expire together.
  • Expiry is delivery-independent — the clock starts when the message is created, not when it is delivered or read.

What you observe:

  • No expiry timestamp is exposed. API responses and webhook payloads do not include the deletion time. If you need it, compute created_at + 24h yourself.
  • No deletion webhook is sent. There is no message.deleted event — a message simply stops being retrievable once its window passes.
  • Delivery is unaffected. Ephemeral messages send, deliver, and fire the usual message.sent / message.received and status webhooks exactly like standard messages. Only retention changes.

When to choose ephemeral:

  • You have a compliance requirement that the platform must not retain message content beyond a short window.
  • The conversation is high-sensitivity (PHI, financial, identity verification) and you do not want it sitting in storage long-term.
  • Your application is the system of record — you capture what you need from the delivery webhook in real time and do not rely on reading message history back from Linq later.

Important: ephemeral applies in both directions — messages you send and messages received by the phone numbers in that scope. Because Linq can no longer return the message after 24 hours, persist anything you need to keep from the webhook payload at the time it is delivered.

Send a message to an existing chat
POST/v3/chats/{chatId}/messages
Get messages from a chat
GET/v3/chats/{chatId}/messages

ChatsLocation

Request a contact’s location, retrieve location for contacts sharing with you, and subscribe to webhooks when someone starts or stops sharing.

Coordinates are returned in GeoJSON format: [longitude, latitude] or [longitude, latitude, altitude] if altitude is available.

Reading location is poll-based

Poll GET /v3/chats/{chatId}/location whenever you need the latest position. There is no webhook that pushes updated coordinates — the location.sharing.started / location.sharing.stopped webhooks fire only when a contact begins or ends sharing, not on each position update. To track a moving contact, poll the GET endpoint.

Freshness

Each feature’s properties.updated_at tells you when that participant’s location was last updated — use it to judge freshness.

Polling guidance

Locations refresh on Apple’s cadence, not per request — polling faster than a participant’s location actually updates just returns the same position. Poll at a modest interval (for example, once every few minutes per chat) rather than continuously.

Why is location empty after location.sharing.started fired?

If the contact started sharing from the standalone Find My app instead of the Messages conversation, the share may be tied to their Apple ID email rather than their phone number — the webhook’s shared_by field shows the email in that case. Location is readable only through a chat with the handle that shared, so GET /v3/chats/{chatId}/location on the phone-number chat stays empty.

The fix: have the contact stop sharing and re-share from Find My inside the Messages conversation with your number.

Request location sharing
POST/v3/chats/{chatId}/location/request
Get location data
GET/v3/chats/{chatId}/location

Messages

Messages are individual communications within a chat thread.

Messages can include text, media attachments, rich link previews, special effects (like confetti or fireworks), and reactions. All messages are associated with a specific chat and sent from a phone number you own.

Messages support delivery status tracking, read receipts, and editing capabilities.

Send a URL as a link part to deliver it with a rich preview card showing the page’s title, description, and image (when available). A link part must be the only part in the message — it cannot be combined with text or media parts. To send a URL without a preview card, include it in a text part instead.

Limitations:

  • A link part cannot be combined with other parts in the same message.
  • Maximum URL length: 2,048 characters.

Ephemeral Messages (Privacy Tier)

For regulated or sensitive conversations, opt in to the ephemeral messages tier by contacting your Linq support contact. When enabled, every message on the covered phone numbers is automatically given a fixed 24-hour retention window — after that window the platform permanently deletes the message from Linq storage. There is no per-message flag; ephemerality is applied automatically based on your configuration.

You can request it at two scopes:

ScopeEffect
Partner-wideEvery outbound and inbound message on every phone number under your account is retained for 24 hours, then deleted.
Per phone numberOnly the specified phone numbers have their messages auto-deleted. The rest follow the standard message-retention policy.

Behavioral differences vs the standard default:

AspectStandardEphemeral
RetentionRetained per the standard message-retention policyHard backstop: 24 hours from when the message is created
After expiryMessage stays retrievableMessage is permanently deleted — GET /v3/messages/{messageId} returns 404 and it no longer appears in GET /v3/chats/{chatId}/messages
Content on expiryN/AText, formatting, and attachment references are scrubbed; the message is gone, not blanked out
Cross-partner isolationEnforcedEnforced

How the 24-hour window works:

  • The window is fixed at 24 hours from message creation (created_at) and cannot be configured per message.
  • It mirrors the ephemeral attachments 1-day backstop, so a message and any media it carries expire together.
  • Expiry is delivery-independent — the clock starts when the message is created, not when it is delivered or read.

What you observe:

  • No expiry timestamp is exposed. API responses and webhook payloads do not include the deletion time. If you need it, compute created_at + 24h yourself.
  • No deletion webhook is sent. There is no message.deleted event — a message simply stops being retrievable once its window passes.
  • Delivery is unaffected. Ephemeral messages send, deliver, and fire the usual message.sent / message.received and status webhooks exactly like standard messages. Only retention changes.

When to choose ephemeral:

  • You have a compliance requirement that the platform must not retain message content beyond a short window.
  • The conversation is high-sensitivity (PHI, financial, identity verification) and you do not want it sitting in storage long-term.
  • Your application is the system of record — you capture what you need from the delivery webhook in real time and do not rely on reading message history back from Linq later.

Important: ephemeral applies in both directions — messages you send and messages received by the phone numbers in that scope. Because Linq can no longer return the message after 24 hours, persist anything you need to keep from the webhook payload at the time it is delivered.

Get all messages in a thread
GET/v3/messages/{messageId}/thread
Get a message by ID
GET/v3/messages/{messageId}
Delete a message from system
DELETE/v3/messages/{messageId}
Add or remove a reaction to a message
POST/v3/messages/{messageId}/reactions
Edit the content of a message part
PATCH/v3/messages/{messageId}
Update an iMessage app card in place
POST/v3/messages/{messageId}/update

Attachments

Send files (images, videos, documents, audio) with messages by providing a URL in a media part. Pre-uploading via POST /v3/attachments is optional and only needed for specific optimization scenarios.

Sending Media via URL (up to 10MB)

Provide a publicly accessible HTTPS URL with a supported media type in the url field of a media part.

{
  "parts": [
    { "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
  ]
}

This works with any URL you already host — no pre-upload step required. Maximum file size: 10MB.

Pre-Upload (required for files over 10MB)

Use POST /v3/attachments when you want to:

  • Send files larger than 10MB (up to 100MB) — URL-based downloads are limited to 10MB
  • Send the same file to many recipients — upload once, reuse the attachment_id without re-downloading each time
  • Reduce message send latency — the file is already stored, so sending is faster

How it works:

  1. POST /v3/attachments with file metadata → returns a presigned upload_url (valid for 15 minutes) and a permanent attachment_id
  2. PUT the raw file bytes to the upload_url with the required_headers (no JSON or multipart — just the binary content)
  3. Reference the attachment_id in your media part when sending messages (no expiration)

Key difference: When you provide an external url, we download and process the file on every send. When you use a pre-uploaded attachment_id, the file is already stored — so repeated sends skip the download step entirely.

Domain Allowlisting

Attachment URLs in API responses are served from cdn.linqapp.com. This includes:

  • url fields in media and voice memo message parts
  • download_url fields in attachment and upload response objects

If your application enforces domain allowlists (e.g., for SSRF protection), add:

cdn.linqapp.com

Supported File Types

  • Images: JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
  • Videos: MP4, MOV, M4V
  • Audio: M4A, AAC, MP3, WAV, AIFF, CAF, AMR
  • Documents: PDF, TXT, RTF, CSV, Office formats, ZIP
  • Contact & Calendar: VCF, ICS

Audio: Attachment vs Voice Memo

Audio files sent as media parts appear as downloadable file attachments in iMessage. To send audio as an iMessage voice memo bubble (with native inline playback UI), use the dedicated POST /v3/chats/{chatId}/voicememo endpoint instead.

File Size Limits

  • URL-based (url field): 10MB maximum
  • Pre-upload (attachment_id): 100MB maximum

Security & Ownership

Every attachment is bound to the partner account that created or received it. The API enforces ownership on every operation that touches an attachment — sending, retrieving, deleting.

What this means for you:

  • An attachment created under your API key can only be referenced by your API key.
  • Submitting another partner’s attachment_id returns 404 Not Found. We do not disclose whether the id exists or belongs to someone else.
  • Submitting a CDN URL that resolves to another partner’s attachment is rejected before the send is attempted.
  • Ownership enforcement applies uniformly across send, create-chat, voice memo, retrieve, and delete operations.

Every attachment-affecting endpoint requires a valid partner API key. Unauthenticated calls return 401 Unauthorized.

Attachment URL Patterns

Attachment URLs in API responses and webhook payloads use one of two layouts, depending on the attachment’s tier:

TierURL patternTTL
Persistent (default)https://cdn.linqapp.com/attachments/partners/{partner_id}/{attachment_id}/{filename}Long-lived
EphemeralPre-signed URL pointing at the ephemeral prefix on cdn.linqapp.com15 minutes per signed URL — re-fetch via the API for a fresh URL

Inbound media you receive over webhooks uses the same layout your outbound sends produce, so the URL you store and the URL you build look identical — no special casing in your client.

Ephemeral Attachments (Privacy Tier)

For regulated or sensitive content, opt in to the ephemeral attachments tier by contacting your Linq support contact. You can request it at two scopes:

ScopeEffect
Partner-wideEvery outbound and inbound attachment on every phone number under your account is routed through the ephemeral tier.
Per phone numberOnly the specified phone numbers route their attachments through the ephemeral tier. The rest stay on the persistent tier.

Behavioral differences vs the persistent default:

AspectPersistentEphemeral
Download URL formLong-lived CDN URLPre-signed URL with short TTL
Retention floorIndefinite (until you call DELETE)Hard backstop: 1 day — even without an explicit DELETE, the platform removes the underlying bytes after 24 hours
URL re-fetchNot requiredFetch via GET /v3/attachments/{attachmentId} for a fresh signed URL after TTL expiry
Cross-partner isolationEnforcedEnforced

When to choose ephemeral:

  • Your downstream system processes the file immediately on receipt and does not need to re-read it later.
  • You have a compliance requirement that the platform must not retain attachments beyond a short window.
  • The content is high-sensitivity (PHI, financial documents, identity verification) and you do not want it sitting behind a long-lived URL.

Important: ephemeral applies in both directions — outbound files you upload and inbound media received by the phone numbers in that scope. Download bytes you need to keep promptly, or fetch a fresh signed URL via the API when needed.

Deleting an Attachment

To permanently remove an attachment you own, use:

DELETE /v3/attachments/{attachmentId}
Authorization: Bearer <your_api_key>

What this does:

  1. Verifies the attachment is owned by your account. Returns 404 otherwise.
  2. Removes the underlying file from Linq storage.
  3. Records an audit entry (timestamp, partner, attachment id).

Response codes:

StatusMeaning
204 No ContentDeletion succeeded. The attachment is removed from Linq storage.
400 Bad RequestattachmentId is not a valid UUID.
401 UnauthorizedMissing or invalid API key.
404 Not FoundAttachment does not exist or is not owned by your account.
500 Internal Server ErrorTransient infrastructure issue — safe to retry.

Effect on message history:

  • Messages that referenced the deleted attachment remain visible.
  • The message part that pointed at the attachment is preserved with no attachment reference.
  • Webhook payloads previously delivered to you retain the original URL string, but downloads from that URL return 404 going forward.

Deletion is irreversible. Once 204 is returned, the bytes are gone — there is no undelete.

Inbound Media Flow

When one of your phone numbers receives a message with media (image, video, audio, document), the platform:

  1. Stores the file under your partner account.
  2. Records metadata linked to the inbound message.
  3. Delivers a webhook whose parts[] array includes a media part with a url pointing at cdn.linqapp.com.
  4. If the receiving phone is opted in to ephemeral, the url is a short-TTL signed URL.

You can acknowledge the webhook without fetching the file inline, and lazy-load via GET /v3/attachments/{attachmentId} later. For ephemeral attachments, retrieving via the API always returns a freshly-signed URL.

Data Lifecycle Summary

DataPersistent tierEphemeral tier
Attachment bytesRetained until you DELETEAuto-removed after 1 day, also removable via DELETE
Attachment metadata (id, filename, mime type, size)Retained until you DELETERemoved alongside the bytes
Message body & partsRetained per message-retention policyRetained per message-retention policy — unless the line also has ephemeral messages enabled (see the Messages page), in which case the message and its parts are deleted 24 hours after creation
Audit log of deletionsRetained per platform retention policyRetained per platform retention policy

In transit: TLS 1.2+ everywhere. At rest: AES-256 (server-side encryption).

Compliance Checklist

If you’re integrating Linq under a security or privacy review, here is the short list:

  • Allowlist exactly one outbound domain: cdn.linqapp.com.
  • Decide whether you need ephemeral attachments (high-sensitivity content) — request enablement through your Linq support contact.
  • Implement DELETE /v3/attachments/{attachmentId} calls in your deletion workflow.
  • Persist any attachments your application needs long-term — Linq is the authoritative source until you delete, but the ephemeral tier auto-purges after 1 day.
  • For audit: every deletion is logged on Linq’s side. Surface a confirmation in your application UI based on the 204 response.
  • For end-user “right to delete” requests: enumerate attachment ids and DELETE each. The platform does not provide a partner-wide wipe endpoint — deletion is per-attachment by design.
Pre-upload a file
POST/v3/attachments
Get attachment metadata
GET/v3/attachments/{attachmentId}
Delete an attachment
DELETE/v3/attachments/{attachmentId}

Phonenumbers

Phone Numbers represent the phone numbers assigned to your partner account.

Use the list phone numbers endpoint to discover which phone numbers are available for sending messages.

When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers in the from field.

List phone numbers (deprecated)
Deprecated
GET/v3/phonenumbers

Phone Numbers

Phone Numbers represent the phone numbers assigned to your partner account.

Use the list phone numbers endpoint to discover which phone numbers are available for sending messages.

When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers in the from field.

List phone numbers
GET/v3/phone_numbers
Update a phone number
PUT/v3/phone_numbers/{phoneNumberId}

Available Number

Phone Numbers represent the phone numbers assigned to your partner account.

Use the list phone numbers endpoint to discover which phone numbers are available for sending messages.

When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers in the from field.

Get an available sending number
GET/v3/available_number

Payment Requests

Request a payment from a recipient over iMessage. You create a payment request, send its checkout_url to the recipient, and they pay with Apple Pay or card. Funds settle directly to your own Stripe account — Linq never holds the money.

How it works

  1. Create a payment request with an amount and currency. You get back a checkout_url and a status of requested.
  2. Send the checkout_url to the recipient as a link message part so it arrives as a tappable card (see Sending the link below).
  3. The recipient pays on the hosted checkout (Apple Pay App Clip on a supported iPhone, web checkout everywhere else).
  4. You receive a payment.succeeded webhook and the request’s status becomes succeeded. Requests you don’t collect eventually expire.

Connected accounts (Stripe Standard, direct charges)

Payments run on Stripe Connect Standard accounts using direct charges: the charge is created on your connected account and you are the merchant of record. That means the money, the payout schedule, the customer relationship, and the compliance surface are all yours — Linq orchestrates the request and the checkout but is never in the funds flow.

Refunds, disputes, and chargebacks are handled by you, in your own Stripe Dashboard. Because charges settle directly to your account, Linq has no custody of the funds and cannot issue refunds or contest disputes on your behalf — and there is no refund/dispute endpoint in this API by design. Use the Stripe Dashboard (or the Stripe API on your own account) for the money lifecycle after a payment succeeds.

Getting set up

Open Agent Pay in your Linq dashboard (https://zero.linqapp.com/organization/payments), click Connect Stripe, and complete Stripe’s onboarding (business details + a bank account). When your account reaches charges_enabled, request creation unlocks; until you connect Stripe, POST /v3/payment_requests returns 403. You can keep collecting even while Stripe finishes background verification.

Subscriptions

Set mode: subscription on POST /v3/payment_requests to start an auto-renewing subscription instead of a one-time charge. Instead of an amount, you pass a price_id — an active recurring Price on your connected Stripe account (create one in your Stripe Dashboard under Product catalog; if you sell through Stripe Payment Links today, reuse the price your link is built from). The recipient pays the first invoice at the same checkout, and their payment method is saved to the subscription for automatic renewals.

The division of labor is deliberate: Linq handles the first payment, your Stripe account handles the rest. The request reaches succeeded when the first invoice is paid; from then on the subscription lives entirely on your connected account. The response’s stripe object gives you the join keys — customer_id and subscription_id — so renewals, plan changes, dunning, and cancellation are managed with your own Stripe Dashboard/API and your own Stripe webhooks. Your metadata is stamped on the Customer and Subscription, so correlating in either direction is trivial. There are no renewal webhooks from Linq by design.

Free trials

Add trial_period_days (or a fixed trial_end timestamp) to start the subscription with a free trial. The checkout still collects the recipient’s payment method — the pay sheet shows “$0 due today” with the first charge date — and saves it to the subscription; Stripe bills it automatically when the trial ends. The request reaches succeeded when the card is collected, and the response carries trial_end. If the trial would end without a payment method on file, the subscription cancels rather than generating unpayable invoices. Trial lifecycle after checkout (extending, ending early) is managed in your own Stripe account via stripe.subscription_id.

A subscription request you cancel (or that expires unpaid) cancels the incomplete Stripe subscription — nothing lingers on your account.

Pre-created customers

By default each request stands alone: payment mode attaches no Customer, and subscription mode creates a fresh one. If you already manage Customers on your connected account, pass their id as customer_id (cus_...) on create — in payment mode the charge lands on that customer’s payment history, and in subscription mode the subscription is created on them instead of on a new Customer. The id must reference an existing, non-deleted customer on your connected account or the request fails with 400. We never modify a customer you pass — no metadata is stamped on it.

Deliver the checkout_url as a link message part via POST /v3/chats/{chatId}/messages — it renders as a rich card with your branding (title, amount, image) instead of a bare URL, which converts far better. A link part must be the only part in the message. See Rich Link Previews.

On a supported iPhone the link opens an Apple Pay App Clip — a native, no-install checkout sheet. Everywhere else (Android, desktop, iPhones without the App Clip yet) the same URL opens the web checkout, so the link always works. The App Clip experience for your payment links is registered automatically by Linq and refreshed whenever you update your payments branding; a newly registered experience can take up to ~24 hours to activate on Apple’s side, during which links open the web checkout.

Webhooks

Subscribe to payment lifecycle events to reconcile server-side rather than polling: payment.succeeded, payment.canceled, and payment.expired. Each event carries the payment request id, amount, currency, and your metadata. See Webhooks.

Create a payment request
POST/v3/payment_requests
Get a payment request
GET/v3/payment_requests/{paymentRequestId}
List payment requests
GET/v3/payment_requests
Cancel a payment request
POST/v3/payment_requests/{paymentRequestId}/cancel

Payment Providers

Let an agent pay on a customer’s behalf with a single-use virtual card. Connect a customer once, then create a payment — a virtual card is minted scoped to that purchase and the card details are handed back for checkout.

Start payment-provider onboarding
POST/v3/payments/providers/{provider}/connect
Get payment-provider status
GET/v3/payments/providers/{provider}

Payment Handles

Let an agent pay on a customer’s behalf with a single-use virtual card. Connect a customer once, then create a payment — a virtual card is minted scoped to that purchase and the card details are handed back for checkout.

Connect a customer handle
POST/v3/payments/handles/{handle}/connect
Submit a customer's one-time code
POST/v3/payments/handles/{handle}/verify
Get a handle's connection status
GET/v3/payments/handles/{handle}/connection
Revoke a handle's connection
DELETE/v3/payments/handles/{handle}/connection

Payments

Let an agent pay on a customer’s behalf with a single-use virtual card. Connect a customer once, then create a payment — a virtual card is minted scoped to that purchase and the card details are handed back for checkout.

Get a payment
GET/v3/payments/{paymentId}
Cancel a payment
POST/v3/payments/{paymentId}/cancel
Get a payment's card-reveal handoff
GET/v3/payments/{paymentId}/credentials

Experiences

Let an agent pay on a customer’s behalf with a single-use virtual card. Connect a customer once, then create a payment — a virtual card is minted scoped to that purchase and the card details are handed back for checkout.

Get one experience
GET/v3/experiences/{experience}

Webhook Events

Webhook Subscriptions allow you to receive real-time notifications when events occur on your account.

Configure webhook endpoints to receive events such as messages sent/received, delivery status changes, reactions, typing indicators, and more.

Failed deliveries (5xx, 429, network errors) are retried up to 10 times over ~25 minutes with exponential backoff. Each event includes a unique ID for deduplication.

Webhook Headers

All webhook requests include two sets of headers. If you have an existing integration using the X-Webhook-* headers, nothing changes — those headers are still sent on every delivery and work exactly as before. The new webhook-* headers follow the Standard Webhooks specification. You can safely ignore them if your current verification code works and you don’t want to use this convention.

Used by our SDK and any Standard Webhooks library.

HeaderDescription
webhook-idUnique event identifier (use as idempotency key)
webhook-timestampUnix timestamp (seconds) when the webhook was sent
webhook-signatureStandard Webhooks signature (v1,{base64} format)

Legacy Headers (Deprecated)

Still sent on every delivery for backwards compatibility. Existing verification code using these headers continues to work — no changes required.

HeaderDescription
X-Webhook-Event(deprecated) Event type (e.g., message.sent)
X-Webhook-Subscription-ID(deprecated) Webhook subscription ID
X-Webhook-Timestamp(deprecated) Unix timestamp (seconds)
X-Webhook-Signature(deprecated) HMAC-SHA256 signature (hex-encoded)

Signing Secrets

Signing secrets use the Standard Webhooks format: a whsec_ prefix followed by base64-encoded random bytes (e.g., whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Jxx2Oll+OE=).

Strip the whsec_ prefix and base64-decode the remainder to get the raw key bytes.

Verifying Webhook Signatures

Webhooks are signed following the Standard Webhooks specification. You can use any Standard Webhooks library to verify signatures, or implement verification manually:

Signed content: {webhook-id}.{webhook-timestamp}.{body}

Verification Steps:

  1. Extract the webhook-id, webhook-timestamp, and webhook-signature headers
  2. Reject if the timestamp is more than 5 minutes old (replay protection)
  3. Get the raw request body bytes (do not parse and re-serialize)
  4. Construct signed content: "{webhook-id}.{webhook-timestamp}.{body}"
  5. Strip the whsec_ prefix from your secret and base64-decode to get key bytes
  6. Compute HMAC-SHA256 using the key bytes over the signed content
  7. Base64-encode the result and compare with the value after v1, in webhook-signature
  8. Use constant-time comparison to prevent timing attacks

Example (Python):

import base64, hmac, hashlib

def verify_webhook(secret, body, headers):
    msg_id = headers['webhook-id']
    timestamp = headers['webhook-timestamp']
    signature = headers['webhook-signature']

    secret_str = secret.removeprefix('whsec_')
    key = base64.b64decode(secret_str)

    signed_content = f"{msg_id}.{timestamp}.{body}"
    expected = base64.b64encode(
        hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    for sig in signature.split(' '):
        if sig.startswith('v1,') and hmac.compare_digest(expected, sig[3:]):
            return True
    return False

Example (Node.js):

const crypto = require('crypto');

function verifyWebhook(secret, rawBody, headers) {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signature = headers['webhook-signature'];

  const secretStr = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const keyBytes = Buffer.from(secretStr, 'base64');
  const signedContent = `${msgId}.${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', keyBytes)
    .update(signedContent)
    .digest('base64');

  return signature.split(' ').some(sig => {
    if (!sig.startsWith('v1,')) return false;
    try {
      return crypto.timingSafeEqual(
        Buffer.from(expected, 'base64'),
        Buffer.from(sig.slice(3), 'base64')
      );
    } catch { return false; }
  });
}

Security Best Practices:

  • Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
  • Always use constant-time comparison for signature verification
  • Store your signing secret securely (e.g., environment variable, secrets manager)
  • Return a 2xx status code quickly, then process the webhook asynchronously

Webhook Subscriptions

Webhook Subscriptions allow you to receive real-time notifications when events occur on your account.

Configure webhook endpoints to receive events such as messages sent/received, delivery status changes, reactions, typing indicators, and more.

Failed deliveries (5xx, 429, network errors) are retried up to 10 times over ~25 minutes with exponential backoff. Each event includes a unique ID for deduplication.

Webhook Headers

All webhook requests include two sets of headers. If you have an existing integration using the X-Webhook-* headers, nothing changes — those headers are still sent on every delivery and work exactly as before. The new webhook-* headers follow the Standard Webhooks specification. You can safely ignore them if your current verification code works and you don’t want to use this convention.

Used by our SDK and any Standard Webhooks library.

HeaderDescription
webhook-idUnique event identifier (use as idempotency key)
webhook-timestampUnix timestamp (seconds) when the webhook was sent
webhook-signatureStandard Webhooks signature (v1,{base64} format)

Legacy Headers (Deprecated)

Still sent on every delivery for backwards compatibility. Existing verification code using these headers continues to work — no changes required.

HeaderDescription
X-Webhook-Event(deprecated) Event type (e.g., message.sent)
X-Webhook-Subscription-ID(deprecated) Webhook subscription ID
X-Webhook-Timestamp(deprecated) Unix timestamp (seconds)
X-Webhook-Signature(deprecated) HMAC-SHA256 signature (hex-encoded)

Signing Secrets

Signing secrets use the Standard Webhooks format: a whsec_ prefix followed by base64-encoded random bytes (e.g., whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Jxx2Oll+OE=).

Strip the whsec_ prefix and base64-decode the remainder to get the raw key bytes.

Verifying Webhook Signatures

Webhooks are signed following the Standard Webhooks specification. You can use any Standard Webhooks library to verify signatures, or implement verification manually:

Signed content: {webhook-id}.{webhook-timestamp}.{body}

Verification Steps:

  1. Extract the webhook-id, webhook-timestamp, and webhook-signature headers
  2. Reject if the timestamp is more than 5 minutes old (replay protection)
  3. Get the raw request body bytes (do not parse and re-serialize)
  4. Construct signed content: "{webhook-id}.{webhook-timestamp}.{body}"
  5. Strip the whsec_ prefix from your secret and base64-decode to get key bytes
  6. Compute HMAC-SHA256 using the key bytes over the signed content
  7. Base64-encode the result and compare with the value after v1, in webhook-signature
  8. Use constant-time comparison to prevent timing attacks

Example (Python):

import base64, hmac, hashlib

def verify_webhook(secret, body, headers):
    msg_id = headers['webhook-id']
    timestamp = headers['webhook-timestamp']
    signature = headers['webhook-signature']

    secret_str = secret.removeprefix('whsec_')
    key = base64.b64decode(secret_str)

    signed_content = f"{msg_id}.{timestamp}.{body}"
    expected = base64.b64encode(
        hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
    ).decode()

    for sig in signature.split(' '):
        if sig.startswith('v1,') and hmac.compare_digest(expected, sig[3:]):
            return True
    return False

Example (Node.js):

const crypto = require('crypto');

function verifyWebhook(secret, rawBody, headers) {
  const msgId = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signature = headers['webhook-signature'];

  const secretStr = secret.startsWith('whsec_') ? secret.slice(6) : secret;
  const keyBytes = Buffer.from(secretStr, 'base64');
  const signedContent = `${msgId}.${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', keyBytes)
    .update(signedContent)
    .digest('base64');

  return signature.split(' ').some(sig => {
    if (!sig.startsWith('v1,')) return false;
    try {
      return crypto.timingSafeEqual(
        Buffer.from(expected, 'base64'),
        Buffer.from(sig.slice(3), 'base64')
      );
    } catch { return false; }
  });
}

Security Best Practices:

  • Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
  • Always use constant-time comparison for signature verification
  • Store your signing secret securely (e.g., environment variable, secrets manager)
  • Return a 2xx status code quickly, then process the webhook asynchronously
Create a new webhook subscription
POST/v3/webhook-subscriptions
List all webhook subscriptions
GET/v3/webhook-subscriptions
Get a webhook subscription by ID
GET/v3/webhook-subscriptions/{subscriptionId}
Update a webhook subscription
PUT/v3/webhook-subscriptions/{subscriptionId}
Delete a webhook subscription
DELETE/v3/webhook-subscriptions/{subscriptionId}

Capability

Check whether a recipient address supports iMessage or RCS before sending a message.

Check iMessage capability
POST/v3/capability/check_imessage
Check RCS capability
POST/v3/capability/check_rcs

Webhooks

Unwrap
Function

Contact Card

Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.

Use POST /v3/contact_card to create or update a card for a phone number. Use PATCH /v3/contact_card to update an existing active card. Use GET /v3/contact_card to retrieve the active card(s) for your partner account.

Sharing behavior: Sharing may not take effect in every chat due to limitations outside our control. We recommend calling the share endpoint once per day, after the first outbound activity.

Get contact cards
GET/v3/contact_card
Setup contact card
POST/v3/contact_card
Update contact card
PATCH/v3/contact_card