linq-python 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linq/__init__.py +102 -0
- linq/_base_client.py +2149 -0
- linq/_client.py +2479 -0
- linq/_compat.py +226 -0
- linq/_constants.py +14 -0
- linq/_exceptions.py +108 -0
- linq/_files.py +123 -0
- linq/_models.py +878 -0
- linq/_qs.py +153 -0
- linq/_resource.py +43 -0
- linq/_response.py +833 -0
- linq/_streaming.py +338 -0
- linq/_types.py +271 -0
- linq/_utils/__init__.py +65 -0
- linq/_utils/_compat.py +45 -0
- linq/_utils/_datetime_parse.py +136 -0
- linq/_utils/_json.py +35 -0
- linq/_utils/_logs.py +25 -0
- linq/_utils/_path.py +127 -0
- linq/_utils/_proxy.py +65 -0
- linq/_utils/_reflection.py +42 -0
- linq/_utils/_resources_proxy.py +24 -0
- linq/_utils/_streams.py +12 -0
- linq/_utils/_sync.py +58 -0
- linq/_utils/_transform.py +457 -0
- linq/_utils/_typing.py +156 -0
- linq/_utils/_utils.py +421 -0
- linq/_version.py +4 -0
- linq/lib/.keep +4 -0
- linq/pagination.py +95 -0
- linq/py.typed +0 -0
- linq/resources/__init__.py +134 -0
- linq/resources/attachments.py +589 -0
- linq/resources/capability.py +297 -0
- linq/resources/chats/__init__.py +61 -0
- linq/resources/chats/chats.py +1492 -0
- linq/resources/chats/messages.py +416 -0
- linq/resources/chats/participants.py +322 -0
- linq/resources/chats/typing.py +299 -0
- linq/resources/contact_card.py +472 -0
- linq/resources/messages.py +686 -0
- linq/resources/phone_numbers.py +163 -0
- linq/resources/phonenumbers.py +165 -0
- linq/resources/webhook_events.py +319 -0
- linq/resources/webhook_subscriptions.py +776 -0
- linq/resources/webhooks.py +34 -0
- linq/types/__init__.py +90 -0
- linq/types/attachment_create_params.py +42 -0
- linq/types/attachment_create_response.py +44 -0
- linq/types/attachment_retrieve_response.py +55 -0
- linq/types/capability_check_RCS_params.py +20 -0
- linq/types/capability_check_i_message_params.py +20 -0
- linq/types/chat.py +44 -0
- linq/types/chat_create_params.py +33 -0
- linq/types/chat_create_response.py +44 -0
- linq/types/chat_created_webhook_event.py +87 -0
- linq/types/chat_group_icon_update_failed_webhook_event.py +65 -0
- linq/types/chat_group_icon_updated_webhook_event.py +66 -0
- linq/types/chat_group_name_update_failed_webhook_event.py +65 -0
- linq/types/chat_group_name_updated_webhook_event.py +66 -0
- linq/types/chat_leave_chat_response.py +15 -0
- linq/types/chat_list_chats_params.py +36 -0
- linq/types/chat_send_voicememo_params.py +23 -0
- linq/types/chat_send_voicememo_response.py +79 -0
- linq/types/chat_typing_indicator_started_webhook_event.py +52 -0
- linq/types/chat_typing_indicator_stopped_webhook_event.py +52 -0
- linq/types/chat_update_params.py +15 -0
- linq/types/chat_update_response.py +13 -0
- linq/types/chats/__init__.py +12 -0
- linq/types/chats/message_list_params.py +15 -0
- linq/types/chats/message_send_params.py +18 -0
- linq/types/chats/message_send_response.py +16 -0
- linq/types/chats/participant_add_params.py +12 -0
- linq/types/chats/participant_add_response.py +15 -0
- linq/types/chats/participant_remove_params.py +12 -0
- linq/types/chats/participant_remove_response.py +15 -0
- linq/types/chats/sent_message.py +69 -0
- linq/types/contact_card_create_params.py +24 -0
- linq/types/contact_card_retrieve_params.py +15 -0
- linq/types/contact_card_retrieve_response.py +23 -0
- linq/types/contact_card_update_params.py +21 -0
- linq/types/events_webhook_event.py +50 -0
- linq/types/handle_check_response.py +13 -0
- linq/types/link_part_param.py +22 -0
- linq/types/media_part_param.py +54 -0
- linq/types/message.py +87 -0
- linq/types/message_add_reaction_params.py +32 -0
- linq/types/message_add_reaction_response.py +15 -0
- linq/types/message_content_param.py +82 -0
- linq/types/message_delivered_webhook_event.py +65 -0
- linq/types/message_edited_webhook_event.py +100 -0
- linq/types/message_effect.py +23 -0
- linq/types/message_effect_param.py +22 -0
- linq/types/message_event_v2.py +116 -0
- linq/types/message_failed_webhook_event.py +72 -0
- linq/types/message_list_messages_thread_params.py +18 -0
- linq/types/message_read_webhook_event.py +65 -0
- linq/types/message_received_webhook_event.py +65 -0
- linq/types/message_sent_webhook_event.py +65 -0
- linq/types/message_update_params.py +15 -0
- linq/types/participant_added_webhook_event.py +66 -0
- linq/types/participant_removed_webhook_event.py +66 -0
- linq/types/phone_number_list_response.py +20 -0
- linq/types/phone_number_status_updated_webhook_event.py +82 -0
- linq/types/phonenumber_list_response.py +39 -0
- linq/types/reaction_added_webhook_event.py +46 -0
- linq/types/reaction_event_base.py +85 -0
- linq/types/reaction_removed_webhook_event.py +46 -0
- linq/types/reply_to.py +21 -0
- linq/types/reply_to_param.py +21 -0
- linq/types/schemas_media_part_response.py +29 -0
- linq/types/schemas_message_effect.py +18 -0
- linq/types/schemas_text_part_response.py +22 -0
- linq/types/set_contact_card.py +24 -0
- linq/types/shared/__init__.py +9 -0
- linq/types/shared/chat_handle.py +33 -0
- linq/types/shared/media_part_response.py +34 -0
- linq/types/shared/reaction.py +56 -0
- linq/types/shared/reaction_type.py +7 -0
- linq/types/shared/service_type.py +7 -0
- linq/types/shared/text_decoration.py +23 -0
- linq/types/shared/text_part_response.py +26 -0
- linq/types/shared_params/__init__.py +5 -0
- linq/types/shared_params/reaction_type.py +9 -0
- linq/types/shared_params/service_type.py +9 -0
- linq/types/shared_params/text_decoration.py +23 -0
- linq/types/supported_content_type.py +60 -0
- linq/types/text_part_param.py +44 -0
- linq/types/webhook_event_list_response.py +17 -0
- linq/types/webhook_event_type.py +33 -0
- linq/types/webhook_subscription.py +35 -0
- linq/types/webhook_subscription_create_params.py +27 -0
- linq/types/webhook_subscription_create_response.py +46 -0
- linq/types/webhook_subscription_list_response.py +13 -0
- linq/types/webhook_subscription_update_params.py +30 -0
- linq_python-0.1.0.dist-info/METADATA +572 -0
- linq_python-0.1.0.dist-info/RECORD +139 -0
- linq_python-0.1.0.dist-info/WHEEL +4 -0
- linq_python-0.1.0.dist-info/licenses/LICENSE +201 -0
linq/_client.py
ADDED
|
@@ -0,0 +1,2479 @@
|
|
|
1
|
+
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
7
|
+
from typing_extensions import Self, override
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _exceptions
|
|
12
|
+
from ._qs import Querystring
|
|
13
|
+
from ._types import (
|
|
14
|
+
Omit,
|
|
15
|
+
Timeout,
|
|
16
|
+
NotGiven,
|
|
17
|
+
Transport,
|
|
18
|
+
ProxiesTypes,
|
|
19
|
+
RequestOptions,
|
|
20
|
+
not_given,
|
|
21
|
+
)
|
|
22
|
+
from ._utils import is_given, get_async_library
|
|
23
|
+
from ._compat import cached_property
|
|
24
|
+
from ._models import SecurityOptions
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
|
|
27
|
+
from ._exceptions import APIStatusError, LinqApiv3Error
|
|
28
|
+
from ._base_client import (
|
|
29
|
+
DEFAULT_MAX_RETRIES,
|
|
30
|
+
SyncAPIClient,
|
|
31
|
+
AsyncAPIClient,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from .resources import (
|
|
36
|
+
chats,
|
|
37
|
+
messages,
|
|
38
|
+
capability,
|
|
39
|
+
attachments,
|
|
40
|
+
contact_card,
|
|
41
|
+
phonenumbers,
|
|
42
|
+
phone_numbers,
|
|
43
|
+
webhook_events,
|
|
44
|
+
webhook_subscriptions,
|
|
45
|
+
)
|
|
46
|
+
from .resources.messages import MessagesResource, AsyncMessagesResource
|
|
47
|
+
from .resources.webhooks import WebhooksResource, AsyncWebhooksResource
|
|
48
|
+
from .resources.capability import CapabilityResource, AsyncCapabilityResource
|
|
49
|
+
from .resources.attachments import AttachmentsResource, AsyncAttachmentsResource
|
|
50
|
+
from .resources.chats.chats import ChatsResource, AsyncChatsResource
|
|
51
|
+
from .resources.contact_card import ContactCardResource, AsyncContactCardResource
|
|
52
|
+
from .resources.phonenumbers import PhonenumbersResource, AsyncPhonenumbersResource
|
|
53
|
+
from .resources.phone_numbers import PhoneNumbersResource, AsyncPhoneNumbersResource
|
|
54
|
+
from .resources.webhook_events import WebhookEventsResource, AsyncWebhookEventsResource
|
|
55
|
+
from .resources.webhook_subscriptions import WebhookSubscriptionsResource, AsyncWebhookSubscriptionsResource
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"Timeout",
|
|
59
|
+
"Transport",
|
|
60
|
+
"ProxiesTypes",
|
|
61
|
+
"RequestOptions",
|
|
62
|
+
"LinqAPIV3",
|
|
63
|
+
"AsyncLinqAPIV3",
|
|
64
|
+
"Client",
|
|
65
|
+
"AsyncClient",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LinqAPIV3(SyncAPIClient):
|
|
70
|
+
# client options
|
|
71
|
+
api_key: str
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
api_key: str | None = None,
|
|
77
|
+
base_url: str | httpx.URL | None = None,
|
|
78
|
+
timeout: float | Timeout | None | NotGiven = not_given,
|
|
79
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
80
|
+
default_headers: Mapping[str, str] | None = None,
|
|
81
|
+
default_query: Mapping[str, object] | None = None,
|
|
82
|
+
# Configure a custom httpx client.
|
|
83
|
+
# We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
|
|
84
|
+
# See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
|
|
85
|
+
http_client: httpx.Client | None = None,
|
|
86
|
+
# Enable or disable schema validation for data returned by the API.
|
|
87
|
+
# When enabled an error APIResponseValidationError is raised
|
|
88
|
+
# if the API responds with invalid data for the expected schema.
|
|
89
|
+
#
|
|
90
|
+
# This parameter may be removed or changed in the future.
|
|
91
|
+
# If you rely on this feature, please open a GitHub issue
|
|
92
|
+
# outlining your use-case to help us decide if it should be
|
|
93
|
+
# part of our public interface in the future.
|
|
94
|
+
_strict_response_validation: bool = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Construct a new synchronous LinqAPIV3 client instance.
|
|
97
|
+
|
|
98
|
+
This automatically infers the `api_key` argument from the `LINQ_API_V3_API_KEY` environment variable if it is not provided.
|
|
99
|
+
"""
|
|
100
|
+
if api_key is None:
|
|
101
|
+
api_key = os.environ.get("LINQ_API_V3_API_KEY")
|
|
102
|
+
if api_key is None:
|
|
103
|
+
raise LinqApiv3Error(
|
|
104
|
+
"The api_key client option must be set either by passing api_key to the client or by setting the LINQ_API_V3_API_KEY environment variable"
|
|
105
|
+
)
|
|
106
|
+
self.api_key = api_key
|
|
107
|
+
|
|
108
|
+
if base_url is None:
|
|
109
|
+
base_url = os.environ.get("LINQ_API_V3_BASE_URL")
|
|
110
|
+
if base_url is None:
|
|
111
|
+
base_url = f"https://api.linqapp.com/api/partner"
|
|
112
|
+
|
|
113
|
+
super().__init__(
|
|
114
|
+
version=__version__,
|
|
115
|
+
base_url=base_url,
|
|
116
|
+
max_retries=max_retries,
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
http_client=http_client,
|
|
119
|
+
custom_headers=default_headers,
|
|
120
|
+
custom_query=default_query,
|
|
121
|
+
_strict_response_validation=_strict_response_validation,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
@cached_property
|
|
125
|
+
def chats(self) -> ChatsResource:
|
|
126
|
+
from .resources.chats import ChatsResource
|
|
127
|
+
|
|
128
|
+
return ChatsResource(self)
|
|
129
|
+
|
|
130
|
+
@cached_property
|
|
131
|
+
def messages(self) -> MessagesResource:
|
|
132
|
+
"""Messages are individual communications within a chat thread.
|
|
133
|
+
|
|
134
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
135
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
136
|
+
specific chat and sent from a phone number you own.
|
|
137
|
+
|
|
138
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
139
|
+
|
|
140
|
+
## Rich Link Previews
|
|
141
|
+
|
|
142
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
143
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
144
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
145
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
146
|
+
|
|
147
|
+
**Limitations:**
|
|
148
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
149
|
+
- Maximum URL length: 2,048 characters.
|
|
150
|
+
"""
|
|
151
|
+
from .resources.messages import MessagesResource
|
|
152
|
+
|
|
153
|
+
return MessagesResource(self)
|
|
154
|
+
|
|
155
|
+
@cached_property
|
|
156
|
+
def attachments(self) -> AttachmentsResource:
|
|
157
|
+
"""
|
|
158
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
159
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
160
|
+
|
|
161
|
+
## Sending Media via URL (up to 10MB)
|
|
162
|
+
|
|
163
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"parts": [
|
|
168
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
174
|
+
|
|
175
|
+
## Pre-Upload (required for files over 10MB)
|
|
176
|
+
|
|
177
|
+
Use `POST /v3/attachments` when you want to:
|
|
178
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
179
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
180
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
181
|
+
|
|
182
|
+
**How it works:**
|
|
183
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
184
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
185
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
186
|
+
|
|
187
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
188
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
189
|
+
|
|
190
|
+
## Domain Allowlisting
|
|
191
|
+
|
|
192
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
193
|
+
- `url` fields in media and voice memo message parts
|
|
194
|
+
- `download_url` fields in attachment and upload response objects
|
|
195
|
+
|
|
196
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
197
|
+
|
|
198
|
+
```
|
|
199
|
+
cdn.linqapp.com
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Supported File Types
|
|
203
|
+
|
|
204
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
205
|
+
- **Videos:** MP4, MOV, M4V
|
|
206
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
207
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
208
|
+
- **Contact & Calendar:** VCF, ICS
|
|
209
|
+
|
|
210
|
+
## Audio: Attachment vs Voice Memo
|
|
211
|
+
|
|
212
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
213
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
214
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
215
|
+
|
|
216
|
+
## File Size Limits
|
|
217
|
+
|
|
218
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
219
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
220
|
+
"""
|
|
221
|
+
from .resources.attachments import AttachmentsResource
|
|
222
|
+
|
|
223
|
+
return AttachmentsResource(self)
|
|
224
|
+
|
|
225
|
+
@cached_property
|
|
226
|
+
def phonenumbers(self) -> PhonenumbersResource:
|
|
227
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
228
|
+
|
|
229
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
230
|
+
for sending messages.
|
|
231
|
+
|
|
232
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
233
|
+
in the `from` field.
|
|
234
|
+
"""
|
|
235
|
+
from .resources.phonenumbers import PhonenumbersResource
|
|
236
|
+
|
|
237
|
+
return PhonenumbersResource(self)
|
|
238
|
+
|
|
239
|
+
@cached_property
|
|
240
|
+
def phone_numbers(self) -> PhoneNumbersResource:
|
|
241
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
242
|
+
|
|
243
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
244
|
+
for sending messages.
|
|
245
|
+
|
|
246
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
247
|
+
in the `from` field.
|
|
248
|
+
"""
|
|
249
|
+
from .resources.phone_numbers import PhoneNumbersResource
|
|
250
|
+
|
|
251
|
+
return PhoneNumbersResource(self)
|
|
252
|
+
|
|
253
|
+
@cached_property
|
|
254
|
+
def webhook_events(self) -> WebhookEventsResource:
|
|
255
|
+
"""
|
|
256
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
257
|
+
occur on your account.
|
|
258
|
+
|
|
259
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
260
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
261
|
+
|
|
262
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
263
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
264
|
+
deduplication.
|
|
265
|
+
|
|
266
|
+
## Webhook Headers
|
|
267
|
+
|
|
268
|
+
Each webhook request includes the following headers:
|
|
269
|
+
|
|
270
|
+
| Header | Description |
|
|
271
|
+
|--------|-------------|
|
|
272
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
273
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
274
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
275
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
276
|
+
|
|
277
|
+
## Verifying Webhook Signatures
|
|
278
|
+
|
|
279
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
280
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
281
|
+
|
|
282
|
+
**Signature Construction:**
|
|
283
|
+
|
|
284
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
{timestamp}.{payload}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Where:
|
|
291
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
292
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
293
|
+
|
|
294
|
+
**Verification Steps:**
|
|
295
|
+
|
|
296
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
297
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
298
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
299
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
300
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
301
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
302
|
+
|
|
303
|
+
**Example (Python):**
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
import hmac
|
|
307
|
+
import hashlib
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
311
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
312
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
313
|
+
return hmac.compare_digest(expected, signature)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
**Example (Node.js):**
|
|
317
|
+
|
|
318
|
+
```javascript
|
|
319
|
+
const crypto = require('crypto');
|
|
320
|
+
|
|
321
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
322
|
+
const message = `${timestamp}.${payload}`;
|
|
323
|
+
const expected = crypto
|
|
324
|
+
.createHmac('sha256', signingSecret)
|
|
325
|
+
.update(message)
|
|
326
|
+
.digest('hex');
|
|
327
|
+
return crypto.timingSafeEqual(
|
|
328
|
+
Buffer.from(expected),
|
|
329
|
+
Buffer.from(signature)
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
**Security Best Practices:**
|
|
335
|
+
|
|
336
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
337
|
+
- Always use constant-time comparison for signature verification
|
|
338
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
339
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
340
|
+
"""
|
|
341
|
+
from .resources.webhook_events import WebhookEventsResource
|
|
342
|
+
|
|
343
|
+
return WebhookEventsResource(self)
|
|
344
|
+
|
|
345
|
+
@cached_property
|
|
346
|
+
def webhook_subscriptions(self) -> WebhookSubscriptionsResource:
|
|
347
|
+
"""
|
|
348
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
349
|
+
occur on your account.
|
|
350
|
+
|
|
351
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
352
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
353
|
+
|
|
354
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
355
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
356
|
+
deduplication.
|
|
357
|
+
|
|
358
|
+
## Webhook Headers
|
|
359
|
+
|
|
360
|
+
Each webhook request includes the following headers:
|
|
361
|
+
|
|
362
|
+
| Header | Description |
|
|
363
|
+
|--------|-------------|
|
|
364
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
365
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
366
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
367
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
368
|
+
|
|
369
|
+
## Verifying Webhook Signatures
|
|
370
|
+
|
|
371
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
372
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
373
|
+
|
|
374
|
+
**Signature Construction:**
|
|
375
|
+
|
|
376
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
377
|
+
|
|
378
|
+
```
|
|
379
|
+
{timestamp}.{payload}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Where:
|
|
383
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
384
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
385
|
+
|
|
386
|
+
**Verification Steps:**
|
|
387
|
+
|
|
388
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
389
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
390
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
391
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
392
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
393
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
394
|
+
|
|
395
|
+
**Example (Python):**
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
import hmac
|
|
399
|
+
import hashlib
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
403
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
404
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
405
|
+
return hmac.compare_digest(expected, signature)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
**Example (Node.js):**
|
|
409
|
+
|
|
410
|
+
```javascript
|
|
411
|
+
const crypto = require('crypto');
|
|
412
|
+
|
|
413
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
414
|
+
const message = `${timestamp}.${payload}`;
|
|
415
|
+
const expected = crypto
|
|
416
|
+
.createHmac('sha256', signingSecret)
|
|
417
|
+
.update(message)
|
|
418
|
+
.digest('hex');
|
|
419
|
+
return crypto.timingSafeEqual(
|
|
420
|
+
Buffer.from(expected),
|
|
421
|
+
Buffer.from(signature)
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
**Security Best Practices:**
|
|
427
|
+
|
|
428
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
429
|
+
- Always use constant-time comparison for signature verification
|
|
430
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
431
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
432
|
+
"""
|
|
433
|
+
from .resources.webhook_subscriptions import WebhookSubscriptionsResource
|
|
434
|
+
|
|
435
|
+
return WebhookSubscriptionsResource(self)
|
|
436
|
+
|
|
437
|
+
@cached_property
|
|
438
|
+
def capability(self) -> CapabilityResource:
|
|
439
|
+
"""
|
|
440
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
441
|
+
"""
|
|
442
|
+
from .resources.capability import CapabilityResource
|
|
443
|
+
|
|
444
|
+
return CapabilityResource(self)
|
|
445
|
+
|
|
446
|
+
@cached_property
|
|
447
|
+
def webhooks(self) -> WebhooksResource:
|
|
448
|
+
from .resources.webhooks import WebhooksResource
|
|
449
|
+
|
|
450
|
+
return WebhooksResource(self)
|
|
451
|
+
|
|
452
|
+
@cached_property
|
|
453
|
+
def contact_card(self) -> ContactCardResource:
|
|
454
|
+
"""
|
|
455
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
456
|
+
|
|
457
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
458
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
459
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
460
|
+
|
|
461
|
+
**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.
|
|
462
|
+
"""
|
|
463
|
+
from .resources.contact_card import ContactCardResource
|
|
464
|
+
|
|
465
|
+
return ContactCardResource(self)
|
|
466
|
+
|
|
467
|
+
@cached_property
|
|
468
|
+
def with_raw_response(self) -> LinqAPIV3WithRawResponse:
|
|
469
|
+
return LinqAPIV3WithRawResponse(self)
|
|
470
|
+
|
|
471
|
+
@cached_property
|
|
472
|
+
def with_streaming_response(self) -> LinqAPIV3WithStreamedResponse:
|
|
473
|
+
return LinqAPIV3WithStreamedResponse(self)
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
@override
|
|
477
|
+
def qs(self) -> Querystring:
|
|
478
|
+
return Querystring(array_format="comma")
|
|
479
|
+
|
|
480
|
+
@override
|
|
481
|
+
def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
|
|
482
|
+
return {
|
|
483
|
+
**(self._bearer_auth if security.get("bearer_auth", False) else {}),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
@property
|
|
487
|
+
def _bearer_auth(self) -> dict[str, str]:
|
|
488
|
+
api_key = self.api_key
|
|
489
|
+
return {"Authorization": f"Bearer {api_key}"}
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
@override
|
|
493
|
+
def default_headers(self) -> dict[str, str | Omit]:
|
|
494
|
+
return {
|
|
495
|
+
**super().default_headers,
|
|
496
|
+
"X-Stainless-Async": "false",
|
|
497
|
+
**self._custom_headers,
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
def copy(
|
|
501
|
+
self,
|
|
502
|
+
*,
|
|
503
|
+
api_key: str | None = None,
|
|
504
|
+
base_url: str | httpx.URL | None = None,
|
|
505
|
+
timeout: float | Timeout | None | NotGiven = not_given,
|
|
506
|
+
http_client: httpx.Client | None = None,
|
|
507
|
+
max_retries: int | NotGiven = not_given,
|
|
508
|
+
default_headers: Mapping[str, str] | None = None,
|
|
509
|
+
set_default_headers: Mapping[str, str] | None = None,
|
|
510
|
+
default_query: Mapping[str, object] | None = None,
|
|
511
|
+
set_default_query: Mapping[str, object] | None = None,
|
|
512
|
+
_extra_kwargs: Mapping[str, Any] = {},
|
|
513
|
+
) -> Self:
|
|
514
|
+
"""
|
|
515
|
+
Create a new client instance re-using the same options given to the current client with optional overriding.
|
|
516
|
+
"""
|
|
517
|
+
if default_headers is not None and set_default_headers is not None:
|
|
518
|
+
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
|
|
519
|
+
|
|
520
|
+
if default_query is not None and set_default_query is not None:
|
|
521
|
+
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
|
|
522
|
+
|
|
523
|
+
headers = self._custom_headers
|
|
524
|
+
if default_headers is not None:
|
|
525
|
+
headers = {**headers, **default_headers}
|
|
526
|
+
elif set_default_headers is not None:
|
|
527
|
+
headers = set_default_headers
|
|
528
|
+
|
|
529
|
+
params = self._custom_query
|
|
530
|
+
if default_query is not None:
|
|
531
|
+
params = {**params, **default_query}
|
|
532
|
+
elif set_default_query is not None:
|
|
533
|
+
params = set_default_query
|
|
534
|
+
|
|
535
|
+
http_client = http_client or self._client
|
|
536
|
+
return self.__class__(
|
|
537
|
+
api_key=api_key or self.api_key,
|
|
538
|
+
base_url=base_url or self.base_url,
|
|
539
|
+
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
|
540
|
+
http_client=http_client,
|
|
541
|
+
max_retries=max_retries if is_given(max_retries) else self.max_retries,
|
|
542
|
+
default_headers=headers,
|
|
543
|
+
default_query=params,
|
|
544
|
+
**_extra_kwargs,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Alias for `copy` for nicer inline usage, e.g.
|
|
548
|
+
# client.with_options(timeout=10).foo.create(...)
|
|
549
|
+
with_options = copy
|
|
550
|
+
|
|
551
|
+
@override
|
|
552
|
+
def _make_status_error(
|
|
553
|
+
self,
|
|
554
|
+
err_msg: str,
|
|
555
|
+
*,
|
|
556
|
+
body: object,
|
|
557
|
+
response: httpx.Response,
|
|
558
|
+
) -> APIStatusError:
|
|
559
|
+
if response.status_code == 400:
|
|
560
|
+
return _exceptions.BadRequestError(err_msg, response=response, body=body)
|
|
561
|
+
|
|
562
|
+
if response.status_code == 401:
|
|
563
|
+
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
|
|
564
|
+
|
|
565
|
+
if response.status_code == 403:
|
|
566
|
+
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
|
|
567
|
+
|
|
568
|
+
if response.status_code == 404:
|
|
569
|
+
return _exceptions.NotFoundError(err_msg, response=response, body=body)
|
|
570
|
+
|
|
571
|
+
if response.status_code == 409:
|
|
572
|
+
return _exceptions.ConflictError(err_msg, response=response, body=body)
|
|
573
|
+
|
|
574
|
+
if response.status_code == 422:
|
|
575
|
+
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
|
|
576
|
+
|
|
577
|
+
if response.status_code == 429:
|
|
578
|
+
return _exceptions.RateLimitError(err_msg, response=response, body=body)
|
|
579
|
+
|
|
580
|
+
if response.status_code >= 500:
|
|
581
|
+
return _exceptions.InternalServerError(err_msg, response=response, body=body)
|
|
582
|
+
return APIStatusError(err_msg, response=response, body=body)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
class AsyncLinqAPIV3(AsyncAPIClient):
|
|
586
|
+
# client options
|
|
587
|
+
api_key: str
|
|
588
|
+
|
|
589
|
+
def __init__(
|
|
590
|
+
self,
|
|
591
|
+
*,
|
|
592
|
+
api_key: str | None = None,
|
|
593
|
+
base_url: str | httpx.URL | None = None,
|
|
594
|
+
timeout: float | Timeout | None | NotGiven = not_given,
|
|
595
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
596
|
+
default_headers: Mapping[str, str] | None = None,
|
|
597
|
+
default_query: Mapping[str, object] | None = None,
|
|
598
|
+
# Configure a custom httpx client.
|
|
599
|
+
# We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
|
|
600
|
+
# See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details.
|
|
601
|
+
http_client: httpx.AsyncClient | None = None,
|
|
602
|
+
# Enable or disable schema validation for data returned by the API.
|
|
603
|
+
# When enabled an error APIResponseValidationError is raised
|
|
604
|
+
# if the API responds with invalid data for the expected schema.
|
|
605
|
+
#
|
|
606
|
+
# This parameter may be removed or changed in the future.
|
|
607
|
+
# If you rely on this feature, please open a GitHub issue
|
|
608
|
+
# outlining your use-case to help us decide if it should be
|
|
609
|
+
# part of our public interface in the future.
|
|
610
|
+
_strict_response_validation: bool = False,
|
|
611
|
+
) -> None:
|
|
612
|
+
"""Construct a new async AsyncLinqAPIV3 client instance.
|
|
613
|
+
|
|
614
|
+
This automatically infers the `api_key` argument from the `LINQ_API_V3_API_KEY` environment variable if it is not provided.
|
|
615
|
+
"""
|
|
616
|
+
if api_key is None:
|
|
617
|
+
api_key = os.environ.get("LINQ_API_V3_API_KEY")
|
|
618
|
+
if api_key is None:
|
|
619
|
+
raise LinqApiv3Error(
|
|
620
|
+
"The api_key client option must be set either by passing api_key to the client or by setting the LINQ_API_V3_API_KEY environment variable"
|
|
621
|
+
)
|
|
622
|
+
self.api_key = api_key
|
|
623
|
+
|
|
624
|
+
if base_url is None:
|
|
625
|
+
base_url = os.environ.get("LINQ_API_V3_BASE_URL")
|
|
626
|
+
if base_url is None:
|
|
627
|
+
base_url = f"https://api.linqapp.com/api/partner"
|
|
628
|
+
|
|
629
|
+
super().__init__(
|
|
630
|
+
version=__version__,
|
|
631
|
+
base_url=base_url,
|
|
632
|
+
max_retries=max_retries,
|
|
633
|
+
timeout=timeout,
|
|
634
|
+
http_client=http_client,
|
|
635
|
+
custom_headers=default_headers,
|
|
636
|
+
custom_query=default_query,
|
|
637
|
+
_strict_response_validation=_strict_response_validation,
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
@cached_property
|
|
641
|
+
def chats(self) -> AsyncChatsResource:
|
|
642
|
+
from .resources.chats import AsyncChatsResource
|
|
643
|
+
|
|
644
|
+
return AsyncChatsResource(self)
|
|
645
|
+
|
|
646
|
+
@cached_property
|
|
647
|
+
def messages(self) -> AsyncMessagesResource:
|
|
648
|
+
"""Messages are individual communications within a chat thread.
|
|
649
|
+
|
|
650
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
651
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
652
|
+
specific chat and sent from a phone number you own.
|
|
653
|
+
|
|
654
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
655
|
+
|
|
656
|
+
## Rich Link Previews
|
|
657
|
+
|
|
658
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
659
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
660
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
661
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
662
|
+
|
|
663
|
+
**Limitations:**
|
|
664
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
665
|
+
- Maximum URL length: 2,048 characters.
|
|
666
|
+
"""
|
|
667
|
+
from .resources.messages import AsyncMessagesResource
|
|
668
|
+
|
|
669
|
+
return AsyncMessagesResource(self)
|
|
670
|
+
|
|
671
|
+
@cached_property
|
|
672
|
+
def attachments(self) -> AsyncAttachmentsResource:
|
|
673
|
+
"""
|
|
674
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
675
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
676
|
+
|
|
677
|
+
## Sending Media via URL (up to 10MB)
|
|
678
|
+
|
|
679
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
680
|
+
|
|
681
|
+
```json
|
|
682
|
+
{
|
|
683
|
+
"parts": [
|
|
684
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
685
|
+
]
|
|
686
|
+
}
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
690
|
+
|
|
691
|
+
## Pre-Upload (required for files over 10MB)
|
|
692
|
+
|
|
693
|
+
Use `POST /v3/attachments` when you want to:
|
|
694
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
695
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
696
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
697
|
+
|
|
698
|
+
**How it works:**
|
|
699
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
700
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
701
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
702
|
+
|
|
703
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
704
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
705
|
+
|
|
706
|
+
## Domain Allowlisting
|
|
707
|
+
|
|
708
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
709
|
+
- `url` fields in media and voice memo message parts
|
|
710
|
+
- `download_url` fields in attachment and upload response objects
|
|
711
|
+
|
|
712
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
713
|
+
|
|
714
|
+
```
|
|
715
|
+
cdn.linqapp.com
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
## Supported File Types
|
|
719
|
+
|
|
720
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
721
|
+
- **Videos:** MP4, MOV, M4V
|
|
722
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
723
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
724
|
+
- **Contact & Calendar:** VCF, ICS
|
|
725
|
+
|
|
726
|
+
## Audio: Attachment vs Voice Memo
|
|
727
|
+
|
|
728
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
729
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
730
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
731
|
+
|
|
732
|
+
## File Size Limits
|
|
733
|
+
|
|
734
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
735
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
736
|
+
"""
|
|
737
|
+
from .resources.attachments import AsyncAttachmentsResource
|
|
738
|
+
|
|
739
|
+
return AsyncAttachmentsResource(self)
|
|
740
|
+
|
|
741
|
+
@cached_property
|
|
742
|
+
def phonenumbers(self) -> AsyncPhonenumbersResource:
|
|
743
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
744
|
+
|
|
745
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
746
|
+
for sending messages.
|
|
747
|
+
|
|
748
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
749
|
+
in the `from` field.
|
|
750
|
+
"""
|
|
751
|
+
from .resources.phonenumbers import AsyncPhonenumbersResource
|
|
752
|
+
|
|
753
|
+
return AsyncPhonenumbersResource(self)
|
|
754
|
+
|
|
755
|
+
@cached_property
|
|
756
|
+
def phone_numbers(self) -> AsyncPhoneNumbersResource:
|
|
757
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
758
|
+
|
|
759
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
760
|
+
for sending messages.
|
|
761
|
+
|
|
762
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
763
|
+
in the `from` field.
|
|
764
|
+
"""
|
|
765
|
+
from .resources.phone_numbers import AsyncPhoneNumbersResource
|
|
766
|
+
|
|
767
|
+
return AsyncPhoneNumbersResource(self)
|
|
768
|
+
|
|
769
|
+
@cached_property
|
|
770
|
+
def webhook_events(self) -> AsyncWebhookEventsResource:
|
|
771
|
+
"""
|
|
772
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
773
|
+
occur on your account.
|
|
774
|
+
|
|
775
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
776
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
777
|
+
|
|
778
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
779
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
780
|
+
deduplication.
|
|
781
|
+
|
|
782
|
+
## Webhook Headers
|
|
783
|
+
|
|
784
|
+
Each webhook request includes the following headers:
|
|
785
|
+
|
|
786
|
+
| Header | Description |
|
|
787
|
+
|--------|-------------|
|
|
788
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
789
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
790
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
791
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
792
|
+
|
|
793
|
+
## Verifying Webhook Signatures
|
|
794
|
+
|
|
795
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
796
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
797
|
+
|
|
798
|
+
**Signature Construction:**
|
|
799
|
+
|
|
800
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
801
|
+
|
|
802
|
+
```
|
|
803
|
+
{timestamp}.{payload}
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
Where:
|
|
807
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
808
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
809
|
+
|
|
810
|
+
**Verification Steps:**
|
|
811
|
+
|
|
812
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
813
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
814
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
815
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
816
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
817
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
818
|
+
|
|
819
|
+
**Example (Python):**
|
|
820
|
+
|
|
821
|
+
```python
|
|
822
|
+
import hmac
|
|
823
|
+
import hashlib
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
827
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
828
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
829
|
+
return hmac.compare_digest(expected, signature)
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
**Example (Node.js):**
|
|
833
|
+
|
|
834
|
+
```javascript
|
|
835
|
+
const crypto = require('crypto');
|
|
836
|
+
|
|
837
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
838
|
+
const message = `${timestamp}.${payload}`;
|
|
839
|
+
const expected = crypto
|
|
840
|
+
.createHmac('sha256', signingSecret)
|
|
841
|
+
.update(message)
|
|
842
|
+
.digest('hex');
|
|
843
|
+
return crypto.timingSafeEqual(
|
|
844
|
+
Buffer.from(expected),
|
|
845
|
+
Buffer.from(signature)
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
**Security Best Practices:**
|
|
851
|
+
|
|
852
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
853
|
+
- Always use constant-time comparison for signature verification
|
|
854
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
855
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
856
|
+
"""
|
|
857
|
+
from .resources.webhook_events import AsyncWebhookEventsResource
|
|
858
|
+
|
|
859
|
+
return AsyncWebhookEventsResource(self)
|
|
860
|
+
|
|
861
|
+
@cached_property
|
|
862
|
+
def webhook_subscriptions(self) -> AsyncWebhookSubscriptionsResource:
|
|
863
|
+
"""
|
|
864
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
865
|
+
occur on your account.
|
|
866
|
+
|
|
867
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
868
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
869
|
+
|
|
870
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
871
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
872
|
+
deduplication.
|
|
873
|
+
|
|
874
|
+
## Webhook Headers
|
|
875
|
+
|
|
876
|
+
Each webhook request includes the following headers:
|
|
877
|
+
|
|
878
|
+
| Header | Description |
|
|
879
|
+
|--------|-------------|
|
|
880
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
881
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
882
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
883
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
884
|
+
|
|
885
|
+
## Verifying Webhook Signatures
|
|
886
|
+
|
|
887
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
888
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
889
|
+
|
|
890
|
+
**Signature Construction:**
|
|
891
|
+
|
|
892
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
893
|
+
|
|
894
|
+
```
|
|
895
|
+
{timestamp}.{payload}
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Where:
|
|
899
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
900
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
901
|
+
|
|
902
|
+
**Verification Steps:**
|
|
903
|
+
|
|
904
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
905
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
906
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
907
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
908
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
909
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
910
|
+
|
|
911
|
+
**Example (Python):**
|
|
912
|
+
|
|
913
|
+
```python
|
|
914
|
+
import hmac
|
|
915
|
+
import hashlib
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
919
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
920
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
921
|
+
return hmac.compare_digest(expected, signature)
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
**Example (Node.js):**
|
|
925
|
+
|
|
926
|
+
```javascript
|
|
927
|
+
const crypto = require('crypto');
|
|
928
|
+
|
|
929
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
930
|
+
const message = `${timestamp}.${payload}`;
|
|
931
|
+
const expected = crypto
|
|
932
|
+
.createHmac('sha256', signingSecret)
|
|
933
|
+
.update(message)
|
|
934
|
+
.digest('hex');
|
|
935
|
+
return crypto.timingSafeEqual(
|
|
936
|
+
Buffer.from(expected),
|
|
937
|
+
Buffer.from(signature)
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
**Security Best Practices:**
|
|
943
|
+
|
|
944
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
945
|
+
- Always use constant-time comparison for signature verification
|
|
946
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
947
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
948
|
+
"""
|
|
949
|
+
from .resources.webhook_subscriptions import AsyncWebhookSubscriptionsResource
|
|
950
|
+
|
|
951
|
+
return AsyncWebhookSubscriptionsResource(self)
|
|
952
|
+
|
|
953
|
+
@cached_property
|
|
954
|
+
def capability(self) -> AsyncCapabilityResource:
|
|
955
|
+
"""
|
|
956
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
957
|
+
"""
|
|
958
|
+
from .resources.capability import AsyncCapabilityResource
|
|
959
|
+
|
|
960
|
+
return AsyncCapabilityResource(self)
|
|
961
|
+
|
|
962
|
+
@cached_property
|
|
963
|
+
def webhooks(self) -> AsyncWebhooksResource:
|
|
964
|
+
from .resources.webhooks import AsyncWebhooksResource
|
|
965
|
+
|
|
966
|
+
return AsyncWebhooksResource(self)
|
|
967
|
+
|
|
968
|
+
@cached_property
|
|
969
|
+
def contact_card(self) -> AsyncContactCardResource:
|
|
970
|
+
"""
|
|
971
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
972
|
+
|
|
973
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
974
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
975
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
976
|
+
|
|
977
|
+
**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.
|
|
978
|
+
"""
|
|
979
|
+
from .resources.contact_card import AsyncContactCardResource
|
|
980
|
+
|
|
981
|
+
return AsyncContactCardResource(self)
|
|
982
|
+
|
|
983
|
+
@cached_property
|
|
984
|
+
def with_raw_response(self) -> AsyncLinqAPIV3WithRawResponse:
|
|
985
|
+
return AsyncLinqAPIV3WithRawResponse(self)
|
|
986
|
+
|
|
987
|
+
@cached_property
|
|
988
|
+
def with_streaming_response(self) -> AsyncLinqAPIV3WithStreamedResponse:
|
|
989
|
+
return AsyncLinqAPIV3WithStreamedResponse(self)
|
|
990
|
+
|
|
991
|
+
@property
|
|
992
|
+
@override
|
|
993
|
+
def qs(self) -> Querystring:
|
|
994
|
+
return Querystring(array_format="comma")
|
|
995
|
+
|
|
996
|
+
@override
|
|
997
|
+
def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
|
|
998
|
+
return {
|
|
999
|
+
**(self._bearer_auth if security.get("bearer_auth", False) else {}),
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
@property
|
|
1003
|
+
def _bearer_auth(self) -> dict[str, str]:
|
|
1004
|
+
api_key = self.api_key
|
|
1005
|
+
return {"Authorization": f"Bearer {api_key}"}
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
@override
|
|
1009
|
+
def default_headers(self) -> dict[str, str | Omit]:
|
|
1010
|
+
return {
|
|
1011
|
+
**super().default_headers,
|
|
1012
|
+
"X-Stainless-Async": f"async:{get_async_library()}",
|
|
1013
|
+
**self._custom_headers,
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
def copy(
|
|
1017
|
+
self,
|
|
1018
|
+
*,
|
|
1019
|
+
api_key: str | None = None,
|
|
1020
|
+
base_url: str | httpx.URL | None = None,
|
|
1021
|
+
timeout: float | Timeout | None | NotGiven = not_given,
|
|
1022
|
+
http_client: httpx.AsyncClient | None = None,
|
|
1023
|
+
max_retries: int | NotGiven = not_given,
|
|
1024
|
+
default_headers: Mapping[str, str] | None = None,
|
|
1025
|
+
set_default_headers: Mapping[str, str] | None = None,
|
|
1026
|
+
default_query: Mapping[str, object] | None = None,
|
|
1027
|
+
set_default_query: Mapping[str, object] | None = None,
|
|
1028
|
+
_extra_kwargs: Mapping[str, Any] = {},
|
|
1029
|
+
) -> Self:
|
|
1030
|
+
"""
|
|
1031
|
+
Create a new client instance re-using the same options given to the current client with optional overriding.
|
|
1032
|
+
"""
|
|
1033
|
+
if default_headers is not None and set_default_headers is not None:
|
|
1034
|
+
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
|
|
1035
|
+
|
|
1036
|
+
if default_query is not None and set_default_query is not None:
|
|
1037
|
+
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
|
|
1038
|
+
|
|
1039
|
+
headers = self._custom_headers
|
|
1040
|
+
if default_headers is not None:
|
|
1041
|
+
headers = {**headers, **default_headers}
|
|
1042
|
+
elif set_default_headers is not None:
|
|
1043
|
+
headers = set_default_headers
|
|
1044
|
+
|
|
1045
|
+
params = self._custom_query
|
|
1046
|
+
if default_query is not None:
|
|
1047
|
+
params = {**params, **default_query}
|
|
1048
|
+
elif set_default_query is not None:
|
|
1049
|
+
params = set_default_query
|
|
1050
|
+
|
|
1051
|
+
http_client = http_client or self._client
|
|
1052
|
+
return self.__class__(
|
|
1053
|
+
api_key=api_key or self.api_key,
|
|
1054
|
+
base_url=base_url or self.base_url,
|
|
1055
|
+
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
|
1056
|
+
http_client=http_client,
|
|
1057
|
+
max_retries=max_retries if is_given(max_retries) else self.max_retries,
|
|
1058
|
+
default_headers=headers,
|
|
1059
|
+
default_query=params,
|
|
1060
|
+
**_extra_kwargs,
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
# Alias for `copy` for nicer inline usage, e.g.
|
|
1064
|
+
# client.with_options(timeout=10).foo.create(...)
|
|
1065
|
+
with_options = copy
|
|
1066
|
+
|
|
1067
|
+
@override
|
|
1068
|
+
def _make_status_error(
|
|
1069
|
+
self,
|
|
1070
|
+
err_msg: str,
|
|
1071
|
+
*,
|
|
1072
|
+
body: object,
|
|
1073
|
+
response: httpx.Response,
|
|
1074
|
+
) -> APIStatusError:
|
|
1075
|
+
if response.status_code == 400:
|
|
1076
|
+
return _exceptions.BadRequestError(err_msg, response=response, body=body)
|
|
1077
|
+
|
|
1078
|
+
if response.status_code == 401:
|
|
1079
|
+
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
|
|
1080
|
+
|
|
1081
|
+
if response.status_code == 403:
|
|
1082
|
+
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
|
|
1083
|
+
|
|
1084
|
+
if response.status_code == 404:
|
|
1085
|
+
return _exceptions.NotFoundError(err_msg, response=response, body=body)
|
|
1086
|
+
|
|
1087
|
+
if response.status_code == 409:
|
|
1088
|
+
return _exceptions.ConflictError(err_msg, response=response, body=body)
|
|
1089
|
+
|
|
1090
|
+
if response.status_code == 422:
|
|
1091
|
+
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
|
|
1092
|
+
|
|
1093
|
+
if response.status_code == 429:
|
|
1094
|
+
return _exceptions.RateLimitError(err_msg, response=response, body=body)
|
|
1095
|
+
|
|
1096
|
+
if response.status_code >= 500:
|
|
1097
|
+
return _exceptions.InternalServerError(err_msg, response=response, body=body)
|
|
1098
|
+
return APIStatusError(err_msg, response=response, body=body)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
class LinqAPIV3WithRawResponse:
|
|
1102
|
+
_client: LinqAPIV3
|
|
1103
|
+
|
|
1104
|
+
def __init__(self, client: LinqAPIV3) -> None:
|
|
1105
|
+
self._client = client
|
|
1106
|
+
|
|
1107
|
+
@cached_property
|
|
1108
|
+
def chats(self) -> chats.ChatsResourceWithRawResponse:
|
|
1109
|
+
from .resources.chats import ChatsResourceWithRawResponse
|
|
1110
|
+
|
|
1111
|
+
return ChatsResourceWithRawResponse(self._client.chats)
|
|
1112
|
+
|
|
1113
|
+
@cached_property
|
|
1114
|
+
def messages(self) -> messages.MessagesResourceWithRawResponse:
|
|
1115
|
+
"""Messages are individual communications within a chat thread.
|
|
1116
|
+
|
|
1117
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
1118
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
1119
|
+
specific chat and sent from a phone number you own.
|
|
1120
|
+
|
|
1121
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
1122
|
+
|
|
1123
|
+
## Rich Link Previews
|
|
1124
|
+
|
|
1125
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
1126
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
1127
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
1128
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
1129
|
+
|
|
1130
|
+
**Limitations:**
|
|
1131
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
1132
|
+
- Maximum URL length: 2,048 characters.
|
|
1133
|
+
"""
|
|
1134
|
+
from .resources.messages import MessagesResourceWithRawResponse
|
|
1135
|
+
|
|
1136
|
+
return MessagesResourceWithRawResponse(self._client.messages)
|
|
1137
|
+
|
|
1138
|
+
@cached_property
|
|
1139
|
+
def attachments(self) -> attachments.AttachmentsResourceWithRawResponse:
|
|
1140
|
+
"""
|
|
1141
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
1142
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
1143
|
+
|
|
1144
|
+
## Sending Media via URL (up to 10MB)
|
|
1145
|
+
|
|
1146
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
1147
|
+
|
|
1148
|
+
```json
|
|
1149
|
+
{
|
|
1150
|
+
"parts": [
|
|
1151
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
1152
|
+
]
|
|
1153
|
+
}
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
1157
|
+
|
|
1158
|
+
## Pre-Upload (required for files over 10MB)
|
|
1159
|
+
|
|
1160
|
+
Use `POST /v3/attachments` when you want to:
|
|
1161
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
1162
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
1163
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
1164
|
+
|
|
1165
|
+
**How it works:**
|
|
1166
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
1167
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
1168
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
1169
|
+
|
|
1170
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
1171
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
1172
|
+
|
|
1173
|
+
## Domain Allowlisting
|
|
1174
|
+
|
|
1175
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
1176
|
+
- `url` fields in media and voice memo message parts
|
|
1177
|
+
- `download_url` fields in attachment and upload response objects
|
|
1178
|
+
|
|
1179
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
1180
|
+
|
|
1181
|
+
```
|
|
1182
|
+
cdn.linqapp.com
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
## Supported File Types
|
|
1186
|
+
|
|
1187
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
1188
|
+
- **Videos:** MP4, MOV, M4V
|
|
1189
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
1190
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
1191
|
+
- **Contact & Calendar:** VCF, ICS
|
|
1192
|
+
|
|
1193
|
+
## Audio: Attachment vs Voice Memo
|
|
1194
|
+
|
|
1195
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
1196
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
1197
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
1198
|
+
|
|
1199
|
+
## File Size Limits
|
|
1200
|
+
|
|
1201
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
1202
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
1203
|
+
"""
|
|
1204
|
+
from .resources.attachments import AttachmentsResourceWithRawResponse
|
|
1205
|
+
|
|
1206
|
+
return AttachmentsResourceWithRawResponse(self._client.attachments)
|
|
1207
|
+
|
|
1208
|
+
@cached_property
|
|
1209
|
+
def phonenumbers(self) -> phonenumbers.PhonenumbersResourceWithRawResponse:
|
|
1210
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1211
|
+
|
|
1212
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1213
|
+
for sending messages.
|
|
1214
|
+
|
|
1215
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1216
|
+
in the `from` field.
|
|
1217
|
+
"""
|
|
1218
|
+
from .resources.phonenumbers import PhonenumbersResourceWithRawResponse
|
|
1219
|
+
|
|
1220
|
+
return PhonenumbersResourceWithRawResponse(self._client.phonenumbers)
|
|
1221
|
+
|
|
1222
|
+
@cached_property
|
|
1223
|
+
def phone_numbers(self) -> phone_numbers.PhoneNumbersResourceWithRawResponse:
|
|
1224
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1225
|
+
|
|
1226
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1227
|
+
for sending messages.
|
|
1228
|
+
|
|
1229
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1230
|
+
in the `from` field.
|
|
1231
|
+
"""
|
|
1232
|
+
from .resources.phone_numbers import PhoneNumbersResourceWithRawResponse
|
|
1233
|
+
|
|
1234
|
+
return PhoneNumbersResourceWithRawResponse(self._client.phone_numbers)
|
|
1235
|
+
|
|
1236
|
+
@cached_property
|
|
1237
|
+
def webhook_events(self) -> webhook_events.WebhookEventsResourceWithRawResponse:
|
|
1238
|
+
"""
|
|
1239
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
1240
|
+
occur on your account.
|
|
1241
|
+
|
|
1242
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
1243
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
1244
|
+
|
|
1245
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
1246
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
1247
|
+
deduplication.
|
|
1248
|
+
|
|
1249
|
+
## Webhook Headers
|
|
1250
|
+
|
|
1251
|
+
Each webhook request includes the following headers:
|
|
1252
|
+
|
|
1253
|
+
| Header | Description |
|
|
1254
|
+
|--------|-------------|
|
|
1255
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
1256
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
1257
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
1258
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
1259
|
+
|
|
1260
|
+
## Verifying Webhook Signatures
|
|
1261
|
+
|
|
1262
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
1263
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
1264
|
+
|
|
1265
|
+
**Signature Construction:**
|
|
1266
|
+
|
|
1267
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
1268
|
+
|
|
1269
|
+
```
|
|
1270
|
+
{timestamp}.{payload}
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
Where:
|
|
1274
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
1275
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
1276
|
+
|
|
1277
|
+
**Verification Steps:**
|
|
1278
|
+
|
|
1279
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
1280
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
1281
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
1282
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
1283
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
1284
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
1285
|
+
|
|
1286
|
+
**Example (Python):**
|
|
1287
|
+
|
|
1288
|
+
```python
|
|
1289
|
+
import hmac
|
|
1290
|
+
import hashlib
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
1294
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
1295
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
1296
|
+
return hmac.compare_digest(expected, signature)
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
**Example (Node.js):**
|
|
1300
|
+
|
|
1301
|
+
```javascript
|
|
1302
|
+
const crypto = require('crypto');
|
|
1303
|
+
|
|
1304
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
1305
|
+
const message = `${timestamp}.${payload}`;
|
|
1306
|
+
const expected = crypto
|
|
1307
|
+
.createHmac('sha256', signingSecret)
|
|
1308
|
+
.update(message)
|
|
1309
|
+
.digest('hex');
|
|
1310
|
+
return crypto.timingSafeEqual(
|
|
1311
|
+
Buffer.from(expected),
|
|
1312
|
+
Buffer.from(signature)
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
**Security Best Practices:**
|
|
1318
|
+
|
|
1319
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
1320
|
+
- Always use constant-time comparison for signature verification
|
|
1321
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
1322
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
1323
|
+
"""
|
|
1324
|
+
from .resources.webhook_events import WebhookEventsResourceWithRawResponse
|
|
1325
|
+
|
|
1326
|
+
return WebhookEventsResourceWithRawResponse(self._client.webhook_events)
|
|
1327
|
+
|
|
1328
|
+
@cached_property
|
|
1329
|
+
def webhook_subscriptions(self) -> webhook_subscriptions.WebhookSubscriptionsResourceWithRawResponse:
|
|
1330
|
+
"""
|
|
1331
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
1332
|
+
occur on your account.
|
|
1333
|
+
|
|
1334
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
1335
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
1336
|
+
|
|
1337
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
1338
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
1339
|
+
deduplication.
|
|
1340
|
+
|
|
1341
|
+
## Webhook Headers
|
|
1342
|
+
|
|
1343
|
+
Each webhook request includes the following headers:
|
|
1344
|
+
|
|
1345
|
+
| Header | Description |
|
|
1346
|
+
|--------|-------------|
|
|
1347
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
1348
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
1349
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
1350
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
1351
|
+
|
|
1352
|
+
## Verifying Webhook Signatures
|
|
1353
|
+
|
|
1354
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
1355
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
1356
|
+
|
|
1357
|
+
**Signature Construction:**
|
|
1358
|
+
|
|
1359
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
1360
|
+
|
|
1361
|
+
```
|
|
1362
|
+
{timestamp}.{payload}
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
Where:
|
|
1366
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
1367
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
1368
|
+
|
|
1369
|
+
**Verification Steps:**
|
|
1370
|
+
|
|
1371
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
1372
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
1373
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
1374
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
1375
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
1376
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
1377
|
+
|
|
1378
|
+
**Example (Python):**
|
|
1379
|
+
|
|
1380
|
+
```python
|
|
1381
|
+
import hmac
|
|
1382
|
+
import hashlib
|
|
1383
|
+
|
|
1384
|
+
|
|
1385
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
1386
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
1387
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
1388
|
+
return hmac.compare_digest(expected, signature)
|
|
1389
|
+
```
|
|
1390
|
+
|
|
1391
|
+
**Example (Node.js):**
|
|
1392
|
+
|
|
1393
|
+
```javascript
|
|
1394
|
+
const crypto = require('crypto');
|
|
1395
|
+
|
|
1396
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
1397
|
+
const message = `${timestamp}.${payload}`;
|
|
1398
|
+
const expected = crypto
|
|
1399
|
+
.createHmac('sha256', signingSecret)
|
|
1400
|
+
.update(message)
|
|
1401
|
+
.digest('hex');
|
|
1402
|
+
return crypto.timingSafeEqual(
|
|
1403
|
+
Buffer.from(expected),
|
|
1404
|
+
Buffer.from(signature)
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
**Security Best Practices:**
|
|
1410
|
+
|
|
1411
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
1412
|
+
- Always use constant-time comparison for signature verification
|
|
1413
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
1414
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
1415
|
+
"""
|
|
1416
|
+
from .resources.webhook_subscriptions import WebhookSubscriptionsResourceWithRawResponse
|
|
1417
|
+
|
|
1418
|
+
return WebhookSubscriptionsResourceWithRawResponse(self._client.webhook_subscriptions)
|
|
1419
|
+
|
|
1420
|
+
@cached_property
|
|
1421
|
+
def capability(self) -> capability.CapabilityResourceWithRawResponse:
|
|
1422
|
+
"""
|
|
1423
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
1424
|
+
"""
|
|
1425
|
+
from .resources.capability import CapabilityResourceWithRawResponse
|
|
1426
|
+
|
|
1427
|
+
return CapabilityResourceWithRawResponse(self._client.capability)
|
|
1428
|
+
|
|
1429
|
+
@cached_property
|
|
1430
|
+
def contact_card(self) -> contact_card.ContactCardResourceWithRawResponse:
|
|
1431
|
+
"""
|
|
1432
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
1433
|
+
|
|
1434
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
1435
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
1436
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
1437
|
+
|
|
1438
|
+
**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.
|
|
1439
|
+
"""
|
|
1440
|
+
from .resources.contact_card import ContactCardResourceWithRawResponse
|
|
1441
|
+
|
|
1442
|
+
return ContactCardResourceWithRawResponse(self._client.contact_card)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
class AsyncLinqAPIV3WithRawResponse:
|
|
1446
|
+
_client: AsyncLinqAPIV3
|
|
1447
|
+
|
|
1448
|
+
def __init__(self, client: AsyncLinqAPIV3) -> None:
|
|
1449
|
+
self._client = client
|
|
1450
|
+
|
|
1451
|
+
@cached_property
|
|
1452
|
+
def chats(self) -> chats.AsyncChatsResourceWithRawResponse:
|
|
1453
|
+
from .resources.chats import AsyncChatsResourceWithRawResponse
|
|
1454
|
+
|
|
1455
|
+
return AsyncChatsResourceWithRawResponse(self._client.chats)
|
|
1456
|
+
|
|
1457
|
+
@cached_property
|
|
1458
|
+
def messages(self) -> messages.AsyncMessagesResourceWithRawResponse:
|
|
1459
|
+
"""Messages are individual communications within a chat thread.
|
|
1460
|
+
|
|
1461
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
1462
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
1463
|
+
specific chat and sent from a phone number you own.
|
|
1464
|
+
|
|
1465
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
1466
|
+
|
|
1467
|
+
## Rich Link Previews
|
|
1468
|
+
|
|
1469
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
1470
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
1471
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
1472
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
1473
|
+
|
|
1474
|
+
**Limitations:**
|
|
1475
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
1476
|
+
- Maximum URL length: 2,048 characters.
|
|
1477
|
+
"""
|
|
1478
|
+
from .resources.messages import AsyncMessagesResourceWithRawResponse
|
|
1479
|
+
|
|
1480
|
+
return AsyncMessagesResourceWithRawResponse(self._client.messages)
|
|
1481
|
+
|
|
1482
|
+
@cached_property
|
|
1483
|
+
def attachments(self) -> attachments.AsyncAttachmentsResourceWithRawResponse:
|
|
1484
|
+
"""
|
|
1485
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
1486
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
1487
|
+
|
|
1488
|
+
## Sending Media via URL (up to 10MB)
|
|
1489
|
+
|
|
1490
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
1491
|
+
|
|
1492
|
+
```json
|
|
1493
|
+
{
|
|
1494
|
+
"parts": [
|
|
1495
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
1496
|
+
]
|
|
1497
|
+
}
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
1501
|
+
|
|
1502
|
+
## Pre-Upload (required for files over 10MB)
|
|
1503
|
+
|
|
1504
|
+
Use `POST /v3/attachments` when you want to:
|
|
1505
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
1506
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
1507
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
1508
|
+
|
|
1509
|
+
**How it works:**
|
|
1510
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
1511
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
1512
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
1513
|
+
|
|
1514
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
1515
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
1516
|
+
|
|
1517
|
+
## Domain Allowlisting
|
|
1518
|
+
|
|
1519
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
1520
|
+
- `url` fields in media and voice memo message parts
|
|
1521
|
+
- `download_url` fields in attachment and upload response objects
|
|
1522
|
+
|
|
1523
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
1524
|
+
|
|
1525
|
+
```
|
|
1526
|
+
cdn.linqapp.com
|
|
1527
|
+
```
|
|
1528
|
+
|
|
1529
|
+
## Supported File Types
|
|
1530
|
+
|
|
1531
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
1532
|
+
- **Videos:** MP4, MOV, M4V
|
|
1533
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
1534
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
1535
|
+
- **Contact & Calendar:** VCF, ICS
|
|
1536
|
+
|
|
1537
|
+
## Audio: Attachment vs Voice Memo
|
|
1538
|
+
|
|
1539
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
1540
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
1541
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
1542
|
+
|
|
1543
|
+
## File Size Limits
|
|
1544
|
+
|
|
1545
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
1546
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
1547
|
+
"""
|
|
1548
|
+
from .resources.attachments import AsyncAttachmentsResourceWithRawResponse
|
|
1549
|
+
|
|
1550
|
+
return AsyncAttachmentsResourceWithRawResponse(self._client.attachments)
|
|
1551
|
+
|
|
1552
|
+
@cached_property
|
|
1553
|
+
def phonenumbers(self) -> phonenumbers.AsyncPhonenumbersResourceWithRawResponse:
|
|
1554
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1555
|
+
|
|
1556
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1557
|
+
for sending messages.
|
|
1558
|
+
|
|
1559
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1560
|
+
in the `from` field.
|
|
1561
|
+
"""
|
|
1562
|
+
from .resources.phonenumbers import AsyncPhonenumbersResourceWithRawResponse
|
|
1563
|
+
|
|
1564
|
+
return AsyncPhonenumbersResourceWithRawResponse(self._client.phonenumbers)
|
|
1565
|
+
|
|
1566
|
+
@cached_property
|
|
1567
|
+
def phone_numbers(self) -> phone_numbers.AsyncPhoneNumbersResourceWithRawResponse:
|
|
1568
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1569
|
+
|
|
1570
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1571
|
+
for sending messages.
|
|
1572
|
+
|
|
1573
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1574
|
+
in the `from` field.
|
|
1575
|
+
"""
|
|
1576
|
+
from .resources.phone_numbers import AsyncPhoneNumbersResourceWithRawResponse
|
|
1577
|
+
|
|
1578
|
+
return AsyncPhoneNumbersResourceWithRawResponse(self._client.phone_numbers)
|
|
1579
|
+
|
|
1580
|
+
@cached_property
|
|
1581
|
+
def webhook_events(self) -> webhook_events.AsyncWebhookEventsResourceWithRawResponse:
|
|
1582
|
+
"""
|
|
1583
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
1584
|
+
occur on your account.
|
|
1585
|
+
|
|
1586
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
1587
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
1588
|
+
|
|
1589
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
1590
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
1591
|
+
deduplication.
|
|
1592
|
+
|
|
1593
|
+
## Webhook Headers
|
|
1594
|
+
|
|
1595
|
+
Each webhook request includes the following headers:
|
|
1596
|
+
|
|
1597
|
+
| Header | Description |
|
|
1598
|
+
|--------|-------------|
|
|
1599
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
1600
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
1601
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
1602
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
1603
|
+
|
|
1604
|
+
## Verifying Webhook Signatures
|
|
1605
|
+
|
|
1606
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
1607
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
1608
|
+
|
|
1609
|
+
**Signature Construction:**
|
|
1610
|
+
|
|
1611
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
1612
|
+
|
|
1613
|
+
```
|
|
1614
|
+
{timestamp}.{payload}
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
Where:
|
|
1618
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
1619
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
1620
|
+
|
|
1621
|
+
**Verification Steps:**
|
|
1622
|
+
|
|
1623
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
1624
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
1625
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
1626
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
1627
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
1628
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
1629
|
+
|
|
1630
|
+
**Example (Python):**
|
|
1631
|
+
|
|
1632
|
+
```python
|
|
1633
|
+
import hmac
|
|
1634
|
+
import hashlib
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
1638
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
1639
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
1640
|
+
return hmac.compare_digest(expected, signature)
|
|
1641
|
+
```
|
|
1642
|
+
|
|
1643
|
+
**Example (Node.js):**
|
|
1644
|
+
|
|
1645
|
+
```javascript
|
|
1646
|
+
const crypto = require('crypto');
|
|
1647
|
+
|
|
1648
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
1649
|
+
const message = `${timestamp}.${payload}`;
|
|
1650
|
+
const expected = crypto
|
|
1651
|
+
.createHmac('sha256', signingSecret)
|
|
1652
|
+
.update(message)
|
|
1653
|
+
.digest('hex');
|
|
1654
|
+
return crypto.timingSafeEqual(
|
|
1655
|
+
Buffer.from(expected),
|
|
1656
|
+
Buffer.from(signature)
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
**Security Best Practices:**
|
|
1662
|
+
|
|
1663
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
1664
|
+
- Always use constant-time comparison for signature verification
|
|
1665
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
1666
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
1667
|
+
"""
|
|
1668
|
+
from .resources.webhook_events import AsyncWebhookEventsResourceWithRawResponse
|
|
1669
|
+
|
|
1670
|
+
return AsyncWebhookEventsResourceWithRawResponse(self._client.webhook_events)
|
|
1671
|
+
|
|
1672
|
+
@cached_property
|
|
1673
|
+
def webhook_subscriptions(self) -> webhook_subscriptions.AsyncWebhookSubscriptionsResourceWithRawResponse:
|
|
1674
|
+
"""
|
|
1675
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
1676
|
+
occur on your account.
|
|
1677
|
+
|
|
1678
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
1679
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
1680
|
+
|
|
1681
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
1682
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
1683
|
+
deduplication.
|
|
1684
|
+
|
|
1685
|
+
## Webhook Headers
|
|
1686
|
+
|
|
1687
|
+
Each webhook request includes the following headers:
|
|
1688
|
+
|
|
1689
|
+
| Header | Description |
|
|
1690
|
+
|--------|-------------|
|
|
1691
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
1692
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
1693
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
1694
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
1695
|
+
|
|
1696
|
+
## Verifying Webhook Signatures
|
|
1697
|
+
|
|
1698
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
1699
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
1700
|
+
|
|
1701
|
+
**Signature Construction:**
|
|
1702
|
+
|
|
1703
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
1704
|
+
|
|
1705
|
+
```
|
|
1706
|
+
{timestamp}.{payload}
|
|
1707
|
+
```
|
|
1708
|
+
|
|
1709
|
+
Where:
|
|
1710
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
1711
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
1712
|
+
|
|
1713
|
+
**Verification Steps:**
|
|
1714
|
+
|
|
1715
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
1716
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
1717
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
1718
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
1719
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
1720
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
1721
|
+
|
|
1722
|
+
**Example (Python):**
|
|
1723
|
+
|
|
1724
|
+
```python
|
|
1725
|
+
import hmac
|
|
1726
|
+
import hashlib
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
1730
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
1731
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
1732
|
+
return hmac.compare_digest(expected, signature)
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
**Example (Node.js):**
|
|
1736
|
+
|
|
1737
|
+
```javascript
|
|
1738
|
+
const crypto = require('crypto');
|
|
1739
|
+
|
|
1740
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
1741
|
+
const message = `${timestamp}.${payload}`;
|
|
1742
|
+
const expected = crypto
|
|
1743
|
+
.createHmac('sha256', signingSecret)
|
|
1744
|
+
.update(message)
|
|
1745
|
+
.digest('hex');
|
|
1746
|
+
return crypto.timingSafeEqual(
|
|
1747
|
+
Buffer.from(expected),
|
|
1748
|
+
Buffer.from(signature)
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
```
|
|
1752
|
+
|
|
1753
|
+
**Security Best Practices:**
|
|
1754
|
+
|
|
1755
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
1756
|
+
- Always use constant-time comparison for signature verification
|
|
1757
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
1758
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
1759
|
+
"""
|
|
1760
|
+
from .resources.webhook_subscriptions import AsyncWebhookSubscriptionsResourceWithRawResponse
|
|
1761
|
+
|
|
1762
|
+
return AsyncWebhookSubscriptionsResourceWithRawResponse(self._client.webhook_subscriptions)
|
|
1763
|
+
|
|
1764
|
+
@cached_property
|
|
1765
|
+
def capability(self) -> capability.AsyncCapabilityResourceWithRawResponse:
|
|
1766
|
+
"""
|
|
1767
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
1768
|
+
"""
|
|
1769
|
+
from .resources.capability import AsyncCapabilityResourceWithRawResponse
|
|
1770
|
+
|
|
1771
|
+
return AsyncCapabilityResourceWithRawResponse(self._client.capability)
|
|
1772
|
+
|
|
1773
|
+
@cached_property
|
|
1774
|
+
def contact_card(self) -> contact_card.AsyncContactCardResourceWithRawResponse:
|
|
1775
|
+
"""
|
|
1776
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
1777
|
+
|
|
1778
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
1779
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
1780
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
1781
|
+
|
|
1782
|
+
**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.
|
|
1783
|
+
"""
|
|
1784
|
+
from .resources.contact_card import AsyncContactCardResourceWithRawResponse
|
|
1785
|
+
|
|
1786
|
+
return AsyncContactCardResourceWithRawResponse(self._client.contact_card)
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
class LinqAPIV3WithStreamedResponse:
|
|
1790
|
+
_client: LinqAPIV3
|
|
1791
|
+
|
|
1792
|
+
def __init__(self, client: LinqAPIV3) -> None:
|
|
1793
|
+
self._client = client
|
|
1794
|
+
|
|
1795
|
+
@cached_property
|
|
1796
|
+
def chats(self) -> chats.ChatsResourceWithStreamingResponse:
|
|
1797
|
+
from .resources.chats import ChatsResourceWithStreamingResponse
|
|
1798
|
+
|
|
1799
|
+
return ChatsResourceWithStreamingResponse(self._client.chats)
|
|
1800
|
+
|
|
1801
|
+
@cached_property
|
|
1802
|
+
def messages(self) -> messages.MessagesResourceWithStreamingResponse:
|
|
1803
|
+
"""Messages are individual communications within a chat thread.
|
|
1804
|
+
|
|
1805
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
1806
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
1807
|
+
specific chat and sent from a phone number you own.
|
|
1808
|
+
|
|
1809
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
1810
|
+
|
|
1811
|
+
## Rich Link Previews
|
|
1812
|
+
|
|
1813
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
1814
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
1815
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
1816
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
1817
|
+
|
|
1818
|
+
**Limitations:**
|
|
1819
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
1820
|
+
- Maximum URL length: 2,048 characters.
|
|
1821
|
+
"""
|
|
1822
|
+
from .resources.messages import MessagesResourceWithStreamingResponse
|
|
1823
|
+
|
|
1824
|
+
return MessagesResourceWithStreamingResponse(self._client.messages)
|
|
1825
|
+
|
|
1826
|
+
@cached_property
|
|
1827
|
+
def attachments(self) -> attachments.AttachmentsResourceWithStreamingResponse:
|
|
1828
|
+
"""
|
|
1829
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
1830
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
1831
|
+
|
|
1832
|
+
## Sending Media via URL (up to 10MB)
|
|
1833
|
+
|
|
1834
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
1835
|
+
|
|
1836
|
+
```json
|
|
1837
|
+
{
|
|
1838
|
+
"parts": [
|
|
1839
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
1840
|
+
]
|
|
1841
|
+
}
|
|
1842
|
+
```
|
|
1843
|
+
|
|
1844
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
1845
|
+
|
|
1846
|
+
## Pre-Upload (required for files over 10MB)
|
|
1847
|
+
|
|
1848
|
+
Use `POST /v3/attachments` when you want to:
|
|
1849
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
1850
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
1851
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
1852
|
+
|
|
1853
|
+
**How it works:**
|
|
1854
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
1855
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
1856
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
1857
|
+
|
|
1858
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
1859
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
1860
|
+
|
|
1861
|
+
## Domain Allowlisting
|
|
1862
|
+
|
|
1863
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
1864
|
+
- `url` fields in media and voice memo message parts
|
|
1865
|
+
- `download_url` fields in attachment and upload response objects
|
|
1866
|
+
|
|
1867
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
1868
|
+
|
|
1869
|
+
```
|
|
1870
|
+
cdn.linqapp.com
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
## Supported File Types
|
|
1874
|
+
|
|
1875
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
1876
|
+
- **Videos:** MP4, MOV, M4V
|
|
1877
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
1878
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
1879
|
+
- **Contact & Calendar:** VCF, ICS
|
|
1880
|
+
|
|
1881
|
+
## Audio: Attachment vs Voice Memo
|
|
1882
|
+
|
|
1883
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
1884
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
1885
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
1886
|
+
|
|
1887
|
+
## File Size Limits
|
|
1888
|
+
|
|
1889
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
1890
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
1891
|
+
"""
|
|
1892
|
+
from .resources.attachments import AttachmentsResourceWithStreamingResponse
|
|
1893
|
+
|
|
1894
|
+
return AttachmentsResourceWithStreamingResponse(self._client.attachments)
|
|
1895
|
+
|
|
1896
|
+
@cached_property
|
|
1897
|
+
def phonenumbers(self) -> phonenumbers.PhonenumbersResourceWithStreamingResponse:
|
|
1898
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1899
|
+
|
|
1900
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1901
|
+
for sending messages.
|
|
1902
|
+
|
|
1903
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1904
|
+
in the `from` field.
|
|
1905
|
+
"""
|
|
1906
|
+
from .resources.phonenumbers import PhonenumbersResourceWithStreamingResponse
|
|
1907
|
+
|
|
1908
|
+
return PhonenumbersResourceWithStreamingResponse(self._client.phonenumbers)
|
|
1909
|
+
|
|
1910
|
+
@cached_property
|
|
1911
|
+
def phone_numbers(self) -> phone_numbers.PhoneNumbersResourceWithStreamingResponse:
|
|
1912
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
1913
|
+
|
|
1914
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
1915
|
+
for sending messages.
|
|
1916
|
+
|
|
1917
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
1918
|
+
in the `from` field.
|
|
1919
|
+
"""
|
|
1920
|
+
from .resources.phone_numbers import PhoneNumbersResourceWithStreamingResponse
|
|
1921
|
+
|
|
1922
|
+
return PhoneNumbersResourceWithStreamingResponse(self._client.phone_numbers)
|
|
1923
|
+
|
|
1924
|
+
@cached_property
|
|
1925
|
+
def webhook_events(self) -> webhook_events.WebhookEventsResourceWithStreamingResponse:
|
|
1926
|
+
"""
|
|
1927
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
1928
|
+
occur on your account.
|
|
1929
|
+
|
|
1930
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
1931
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
1932
|
+
|
|
1933
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
1934
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
1935
|
+
deduplication.
|
|
1936
|
+
|
|
1937
|
+
## Webhook Headers
|
|
1938
|
+
|
|
1939
|
+
Each webhook request includes the following headers:
|
|
1940
|
+
|
|
1941
|
+
| Header | Description |
|
|
1942
|
+
|--------|-------------|
|
|
1943
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
1944
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
1945
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
1946
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
1947
|
+
|
|
1948
|
+
## Verifying Webhook Signatures
|
|
1949
|
+
|
|
1950
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
1951
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
1952
|
+
|
|
1953
|
+
**Signature Construction:**
|
|
1954
|
+
|
|
1955
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
1956
|
+
|
|
1957
|
+
```
|
|
1958
|
+
{timestamp}.{payload}
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
Where:
|
|
1962
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
1963
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
1964
|
+
|
|
1965
|
+
**Verification Steps:**
|
|
1966
|
+
|
|
1967
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
1968
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
1969
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
1970
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
1971
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
1972
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
1973
|
+
|
|
1974
|
+
**Example (Python):**
|
|
1975
|
+
|
|
1976
|
+
```python
|
|
1977
|
+
import hmac
|
|
1978
|
+
import hashlib
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
1982
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
1983
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
1984
|
+
return hmac.compare_digest(expected, signature)
|
|
1985
|
+
```
|
|
1986
|
+
|
|
1987
|
+
**Example (Node.js):**
|
|
1988
|
+
|
|
1989
|
+
```javascript
|
|
1990
|
+
const crypto = require('crypto');
|
|
1991
|
+
|
|
1992
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
1993
|
+
const message = `${timestamp}.${payload}`;
|
|
1994
|
+
const expected = crypto
|
|
1995
|
+
.createHmac('sha256', signingSecret)
|
|
1996
|
+
.update(message)
|
|
1997
|
+
.digest('hex');
|
|
1998
|
+
return crypto.timingSafeEqual(
|
|
1999
|
+
Buffer.from(expected),
|
|
2000
|
+
Buffer.from(signature)
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
```
|
|
2004
|
+
|
|
2005
|
+
**Security Best Practices:**
|
|
2006
|
+
|
|
2007
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
2008
|
+
- Always use constant-time comparison for signature verification
|
|
2009
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
2010
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
2011
|
+
"""
|
|
2012
|
+
from .resources.webhook_events import WebhookEventsResourceWithStreamingResponse
|
|
2013
|
+
|
|
2014
|
+
return WebhookEventsResourceWithStreamingResponse(self._client.webhook_events)
|
|
2015
|
+
|
|
2016
|
+
@cached_property
|
|
2017
|
+
def webhook_subscriptions(self) -> webhook_subscriptions.WebhookSubscriptionsResourceWithStreamingResponse:
|
|
2018
|
+
"""
|
|
2019
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
2020
|
+
occur on your account.
|
|
2021
|
+
|
|
2022
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
2023
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
2024
|
+
|
|
2025
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
2026
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
2027
|
+
deduplication.
|
|
2028
|
+
|
|
2029
|
+
## Webhook Headers
|
|
2030
|
+
|
|
2031
|
+
Each webhook request includes the following headers:
|
|
2032
|
+
|
|
2033
|
+
| Header | Description |
|
|
2034
|
+
|--------|-------------|
|
|
2035
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
2036
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
2037
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
2038
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
2039
|
+
|
|
2040
|
+
## Verifying Webhook Signatures
|
|
2041
|
+
|
|
2042
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
2043
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
2044
|
+
|
|
2045
|
+
**Signature Construction:**
|
|
2046
|
+
|
|
2047
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
2048
|
+
|
|
2049
|
+
```
|
|
2050
|
+
{timestamp}.{payload}
|
|
2051
|
+
```
|
|
2052
|
+
|
|
2053
|
+
Where:
|
|
2054
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
2055
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
2056
|
+
|
|
2057
|
+
**Verification Steps:**
|
|
2058
|
+
|
|
2059
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
2060
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
2061
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
2062
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
2063
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
2064
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
2065
|
+
|
|
2066
|
+
**Example (Python):**
|
|
2067
|
+
|
|
2068
|
+
```python
|
|
2069
|
+
import hmac
|
|
2070
|
+
import hashlib
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
2074
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
2075
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
2076
|
+
return hmac.compare_digest(expected, signature)
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
**Example (Node.js):**
|
|
2080
|
+
|
|
2081
|
+
```javascript
|
|
2082
|
+
const crypto = require('crypto');
|
|
2083
|
+
|
|
2084
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
2085
|
+
const message = `${timestamp}.${payload}`;
|
|
2086
|
+
const expected = crypto
|
|
2087
|
+
.createHmac('sha256', signingSecret)
|
|
2088
|
+
.update(message)
|
|
2089
|
+
.digest('hex');
|
|
2090
|
+
return crypto.timingSafeEqual(
|
|
2091
|
+
Buffer.from(expected),
|
|
2092
|
+
Buffer.from(signature)
|
|
2093
|
+
);
|
|
2094
|
+
}
|
|
2095
|
+
```
|
|
2096
|
+
|
|
2097
|
+
**Security Best Practices:**
|
|
2098
|
+
|
|
2099
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
2100
|
+
- Always use constant-time comparison for signature verification
|
|
2101
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
2102
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
2103
|
+
"""
|
|
2104
|
+
from .resources.webhook_subscriptions import WebhookSubscriptionsResourceWithStreamingResponse
|
|
2105
|
+
|
|
2106
|
+
return WebhookSubscriptionsResourceWithStreamingResponse(self._client.webhook_subscriptions)
|
|
2107
|
+
|
|
2108
|
+
@cached_property
|
|
2109
|
+
def capability(self) -> capability.CapabilityResourceWithStreamingResponse:
|
|
2110
|
+
"""
|
|
2111
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
2112
|
+
"""
|
|
2113
|
+
from .resources.capability import CapabilityResourceWithStreamingResponse
|
|
2114
|
+
|
|
2115
|
+
return CapabilityResourceWithStreamingResponse(self._client.capability)
|
|
2116
|
+
|
|
2117
|
+
@cached_property
|
|
2118
|
+
def contact_card(self) -> contact_card.ContactCardResourceWithStreamingResponse:
|
|
2119
|
+
"""
|
|
2120
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
2121
|
+
|
|
2122
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
2123
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
2124
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
2125
|
+
|
|
2126
|
+
**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.
|
|
2127
|
+
"""
|
|
2128
|
+
from .resources.contact_card import ContactCardResourceWithStreamingResponse
|
|
2129
|
+
|
|
2130
|
+
return ContactCardResourceWithStreamingResponse(self._client.contact_card)
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
class AsyncLinqAPIV3WithStreamedResponse:
|
|
2134
|
+
_client: AsyncLinqAPIV3
|
|
2135
|
+
|
|
2136
|
+
def __init__(self, client: AsyncLinqAPIV3) -> None:
|
|
2137
|
+
self._client = client
|
|
2138
|
+
|
|
2139
|
+
@cached_property
|
|
2140
|
+
def chats(self) -> chats.AsyncChatsResourceWithStreamingResponse:
|
|
2141
|
+
from .resources.chats import AsyncChatsResourceWithStreamingResponse
|
|
2142
|
+
|
|
2143
|
+
return AsyncChatsResourceWithStreamingResponse(self._client.chats)
|
|
2144
|
+
|
|
2145
|
+
@cached_property
|
|
2146
|
+
def messages(self) -> messages.AsyncMessagesResourceWithStreamingResponse:
|
|
2147
|
+
"""Messages are individual communications within a chat thread.
|
|
2148
|
+
|
|
2149
|
+
Messages can include text, media attachments, rich link previews, special effects
|
|
2150
|
+
(like confetti or fireworks), and reactions. All messages are associated with a
|
|
2151
|
+
specific chat and sent from a phone number you own.
|
|
2152
|
+
|
|
2153
|
+
Messages support delivery status tracking, read receipts, and editing capabilities.
|
|
2154
|
+
|
|
2155
|
+
## Rich Link Previews
|
|
2156
|
+
|
|
2157
|
+
Send a URL as a `link` part to deliver it with a rich preview card showing the
|
|
2158
|
+
page's title, description, and image (when available). A `link` part must be the
|
|
2159
|
+
**only** part in the message — it cannot be combined with text or media parts.
|
|
2160
|
+
To send a URL without a preview card, include it in a `text` part instead.
|
|
2161
|
+
|
|
2162
|
+
**Limitations:**
|
|
2163
|
+
- A `link` part cannot be combined with other parts in the same message.
|
|
2164
|
+
- Maximum URL length: 2,048 characters.
|
|
2165
|
+
"""
|
|
2166
|
+
from .resources.messages import AsyncMessagesResourceWithStreamingResponse
|
|
2167
|
+
|
|
2168
|
+
return AsyncMessagesResourceWithStreamingResponse(self._client.messages)
|
|
2169
|
+
|
|
2170
|
+
@cached_property
|
|
2171
|
+
def attachments(self) -> attachments.AsyncAttachmentsResourceWithStreamingResponse:
|
|
2172
|
+
"""
|
|
2173
|
+
Send files (images, videos, documents, audio) with messages by providing a URL in a media part.
|
|
2174
|
+
Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios.
|
|
2175
|
+
|
|
2176
|
+
## Sending Media via URL (up to 10MB)
|
|
2177
|
+
|
|
2178
|
+
Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part.
|
|
2179
|
+
|
|
2180
|
+
```json
|
|
2181
|
+
{
|
|
2182
|
+
"parts": [
|
|
2183
|
+
{ "type": "media", "url": "https://your-cdn.com/images/photo.jpg" }
|
|
2184
|
+
]
|
|
2185
|
+
}
|
|
2186
|
+
```
|
|
2187
|
+
|
|
2188
|
+
This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.**
|
|
2189
|
+
|
|
2190
|
+
## Pre-Upload (required for files over 10MB)
|
|
2191
|
+
|
|
2192
|
+
Use `POST /v3/attachments` when you want to:
|
|
2193
|
+
- **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB
|
|
2194
|
+
- **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time
|
|
2195
|
+
- **Reduce message send latency** — the file is already stored, so sending is faster
|
|
2196
|
+
|
|
2197
|
+
**How it works:**
|
|
2198
|
+
1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id`
|
|
2199
|
+
2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content)
|
|
2200
|
+
3. Reference the `attachment_id` in your media part when sending messages (no expiration)
|
|
2201
|
+
|
|
2202
|
+
**Key difference:** When you provide an external `url`, we download and process the file on every send.
|
|
2203
|
+
When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely.
|
|
2204
|
+
|
|
2205
|
+
## Domain Allowlisting
|
|
2206
|
+
|
|
2207
|
+
Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes:
|
|
2208
|
+
- `url` fields in media and voice memo message parts
|
|
2209
|
+
- `download_url` fields in attachment and upload response objects
|
|
2210
|
+
|
|
2211
|
+
If your application enforces domain allowlists (e.g., for SSRF protection), add:
|
|
2212
|
+
|
|
2213
|
+
```
|
|
2214
|
+
cdn.linqapp.com
|
|
2215
|
+
```
|
|
2216
|
+
|
|
2217
|
+
## Supported File Types
|
|
2218
|
+
|
|
2219
|
+
- **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP
|
|
2220
|
+
- **Videos:** MP4, MOV, M4V
|
|
2221
|
+
- **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR
|
|
2222
|
+
- **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP
|
|
2223
|
+
- **Contact & Calendar:** VCF, ICS
|
|
2224
|
+
|
|
2225
|
+
## Audio: Attachment vs Voice Memo
|
|
2226
|
+
|
|
2227
|
+
Audio files sent as media parts appear as **downloadable file attachments** in iMessage.
|
|
2228
|
+
To send audio as an **iMessage voice memo bubble** (with native inline playback UI),
|
|
2229
|
+
use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead.
|
|
2230
|
+
|
|
2231
|
+
## File Size Limits
|
|
2232
|
+
|
|
2233
|
+
- **URL-based (`url` field):** 10MB maximum
|
|
2234
|
+
- **Pre-upload (`attachment_id`):** 100MB maximum
|
|
2235
|
+
"""
|
|
2236
|
+
from .resources.attachments import AsyncAttachmentsResourceWithStreamingResponse
|
|
2237
|
+
|
|
2238
|
+
return AsyncAttachmentsResourceWithStreamingResponse(self._client.attachments)
|
|
2239
|
+
|
|
2240
|
+
@cached_property
|
|
2241
|
+
def phonenumbers(self) -> phonenumbers.AsyncPhonenumbersResourceWithStreamingResponse:
|
|
2242
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
2243
|
+
|
|
2244
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
2245
|
+
for sending messages.
|
|
2246
|
+
|
|
2247
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
2248
|
+
in the `from` field.
|
|
2249
|
+
"""
|
|
2250
|
+
from .resources.phonenumbers import AsyncPhonenumbersResourceWithStreamingResponse
|
|
2251
|
+
|
|
2252
|
+
return AsyncPhonenumbersResourceWithStreamingResponse(self._client.phonenumbers)
|
|
2253
|
+
|
|
2254
|
+
@cached_property
|
|
2255
|
+
def phone_numbers(self) -> phone_numbers.AsyncPhoneNumbersResourceWithStreamingResponse:
|
|
2256
|
+
"""Phone Numbers represent the phone numbers assigned to your partner account.
|
|
2257
|
+
|
|
2258
|
+
Use the list phone numbers endpoint to discover which phone numbers are available
|
|
2259
|
+
for sending messages.
|
|
2260
|
+
|
|
2261
|
+
When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers
|
|
2262
|
+
in the `from` field.
|
|
2263
|
+
"""
|
|
2264
|
+
from .resources.phone_numbers import AsyncPhoneNumbersResourceWithStreamingResponse
|
|
2265
|
+
|
|
2266
|
+
return AsyncPhoneNumbersResourceWithStreamingResponse(self._client.phone_numbers)
|
|
2267
|
+
|
|
2268
|
+
@cached_property
|
|
2269
|
+
def webhook_events(self) -> webhook_events.AsyncWebhookEventsResourceWithStreamingResponse:
|
|
2270
|
+
"""
|
|
2271
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
2272
|
+
occur on your account.
|
|
2273
|
+
|
|
2274
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
2275
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
2276
|
+
|
|
2277
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
2278
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
2279
|
+
deduplication.
|
|
2280
|
+
|
|
2281
|
+
## Webhook Headers
|
|
2282
|
+
|
|
2283
|
+
Each webhook request includes the following headers:
|
|
2284
|
+
|
|
2285
|
+
| Header | Description |
|
|
2286
|
+
|--------|-------------|
|
|
2287
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
2288
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
2289
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
2290
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
2291
|
+
|
|
2292
|
+
## Verifying Webhook Signatures
|
|
2293
|
+
|
|
2294
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
2295
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
2296
|
+
|
|
2297
|
+
**Signature Construction:**
|
|
2298
|
+
|
|
2299
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
2300
|
+
|
|
2301
|
+
```
|
|
2302
|
+
{timestamp}.{payload}
|
|
2303
|
+
```
|
|
2304
|
+
|
|
2305
|
+
Where:
|
|
2306
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
2307
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
2308
|
+
|
|
2309
|
+
**Verification Steps:**
|
|
2310
|
+
|
|
2311
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
2312
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
2313
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
2314
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
2315
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
2316
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
2317
|
+
|
|
2318
|
+
**Example (Python):**
|
|
2319
|
+
|
|
2320
|
+
```python
|
|
2321
|
+
import hmac
|
|
2322
|
+
import hashlib
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
2326
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
2327
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
2328
|
+
return hmac.compare_digest(expected, signature)
|
|
2329
|
+
```
|
|
2330
|
+
|
|
2331
|
+
**Example (Node.js):**
|
|
2332
|
+
|
|
2333
|
+
```javascript
|
|
2334
|
+
const crypto = require('crypto');
|
|
2335
|
+
|
|
2336
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
2337
|
+
const message = `${timestamp}.${payload}`;
|
|
2338
|
+
const expected = crypto
|
|
2339
|
+
.createHmac('sha256', signingSecret)
|
|
2340
|
+
.update(message)
|
|
2341
|
+
.digest('hex');
|
|
2342
|
+
return crypto.timingSafeEqual(
|
|
2343
|
+
Buffer.from(expected),
|
|
2344
|
+
Buffer.from(signature)
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
```
|
|
2348
|
+
|
|
2349
|
+
**Security Best Practices:**
|
|
2350
|
+
|
|
2351
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
2352
|
+
- Always use constant-time comparison for signature verification
|
|
2353
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
2354
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
2355
|
+
"""
|
|
2356
|
+
from .resources.webhook_events import AsyncWebhookEventsResourceWithStreamingResponse
|
|
2357
|
+
|
|
2358
|
+
return AsyncWebhookEventsResourceWithStreamingResponse(self._client.webhook_events)
|
|
2359
|
+
|
|
2360
|
+
@cached_property
|
|
2361
|
+
def webhook_subscriptions(self) -> webhook_subscriptions.AsyncWebhookSubscriptionsResourceWithStreamingResponse:
|
|
2362
|
+
"""
|
|
2363
|
+
Webhook Subscriptions allow you to receive real-time notifications when events
|
|
2364
|
+
occur on your account.
|
|
2365
|
+
|
|
2366
|
+
Configure webhook endpoints to receive events such as messages sent/received,
|
|
2367
|
+
delivery status changes, reactions, typing indicators, and more.
|
|
2368
|
+
|
|
2369
|
+
Failed deliveries (5xx, 429, network errors) are retried up to 10 times over
|
|
2370
|
+
~25 minutes with exponential backoff. Each event includes a unique ID for
|
|
2371
|
+
deduplication.
|
|
2372
|
+
|
|
2373
|
+
## Webhook Headers
|
|
2374
|
+
|
|
2375
|
+
Each webhook request includes the following headers:
|
|
2376
|
+
|
|
2377
|
+
| Header | Description |
|
|
2378
|
+
|--------|-------------|
|
|
2379
|
+
| `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) |
|
|
2380
|
+
| `X-Webhook-Subscription-ID` | Your webhook subscription ID |
|
|
2381
|
+
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |
|
|
2382
|
+
| `X-Webhook-Signature` | HMAC-SHA256 signature for verification |
|
|
2383
|
+
|
|
2384
|
+
## Verifying Webhook Signatures
|
|
2385
|
+
|
|
2386
|
+
All webhooks are signed using HMAC-SHA256. You should always verify the signature
|
|
2387
|
+
to ensure the webhook originated from Linq and hasn't been tampered with.
|
|
2388
|
+
|
|
2389
|
+
**Signature Construction:**
|
|
2390
|
+
|
|
2391
|
+
The signature is computed over a concatenation of the timestamp and payload:
|
|
2392
|
+
|
|
2393
|
+
```
|
|
2394
|
+
{timestamp}.{payload}
|
|
2395
|
+
```
|
|
2396
|
+
|
|
2397
|
+
Where:
|
|
2398
|
+
- `timestamp` is the value from the `X-Webhook-Timestamp` header
|
|
2399
|
+
- `payload` is the raw JSON request body (exact bytes, not re-serialized)
|
|
2400
|
+
|
|
2401
|
+
**Verification Steps:**
|
|
2402
|
+
|
|
2403
|
+
1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers
|
|
2404
|
+
2. Get the raw request body bytes (do not parse and re-serialize)
|
|
2405
|
+
3. Concatenate: `"{timestamp}.{payload}"`
|
|
2406
|
+
4. Compute HMAC-SHA256 using your signing secret as the key
|
|
2407
|
+
5. Hex-encode the result and compare with `X-Webhook-Signature`
|
|
2408
|
+
6. Use constant-time comparison to prevent timing attacks
|
|
2409
|
+
|
|
2410
|
+
**Example (Python):**
|
|
2411
|
+
|
|
2412
|
+
```python
|
|
2413
|
+
import hmac
|
|
2414
|
+
import hashlib
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
def verify_webhook(signing_secret, payload, timestamp, signature):
|
|
2418
|
+
message = f"{timestamp}.{payload.decode('utf-8')}"
|
|
2419
|
+
expected = hmac.new(signing_secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
2420
|
+
return hmac.compare_digest(expected, signature)
|
|
2421
|
+
```
|
|
2422
|
+
|
|
2423
|
+
**Example (Node.js):**
|
|
2424
|
+
|
|
2425
|
+
```javascript
|
|
2426
|
+
const crypto = require('crypto');
|
|
2427
|
+
|
|
2428
|
+
function verifyWebhook(signingSecret, payload, timestamp, signature) {
|
|
2429
|
+
const message = `${timestamp}.${payload}`;
|
|
2430
|
+
const expected = crypto
|
|
2431
|
+
.createHmac('sha256', signingSecret)
|
|
2432
|
+
.update(message)
|
|
2433
|
+
.digest('hex');
|
|
2434
|
+
return crypto.timingSafeEqual(
|
|
2435
|
+
Buffer.from(expected),
|
|
2436
|
+
Buffer.from(signature)
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
```
|
|
2440
|
+
|
|
2441
|
+
**Security Best Practices:**
|
|
2442
|
+
|
|
2443
|
+
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
|
|
2444
|
+
- Always use constant-time comparison for signature verification
|
|
2445
|
+
- Store your signing secret securely (e.g., environment variable, secrets manager)
|
|
2446
|
+
- Return a 2xx status code quickly, then process the webhook asynchronously
|
|
2447
|
+
"""
|
|
2448
|
+
from .resources.webhook_subscriptions import AsyncWebhookSubscriptionsResourceWithStreamingResponse
|
|
2449
|
+
|
|
2450
|
+
return AsyncWebhookSubscriptionsResourceWithStreamingResponse(self._client.webhook_subscriptions)
|
|
2451
|
+
|
|
2452
|
+
@cached_property
|
|
2453
|
+
def capability(self) -> capability.AsyncCapabilityResourceWithStreamingResponse:
|
|
2454
|
+
"""
|
|
2455
|
+
Check whether a recipient address supports iMessage or RCS before sending a message.
|
|
2456
|
+
"""
|
|
2457
|
+
from .resources.capability import AsyncCapabilityResourceWithStreamingResponse
|
|
2458
|
+
|
|
2459
|
+
return AsyncCapabilityResourceWithStreamingResponse(self._client.capability)
|
|
2460
|
+
|
|
2461
|
+
@cached_property
|
|
2462
|
+
def contact_card(self) -> contact_card.AsyncContactCardResourceWithStreamingResponse:
|
|
2463
|
+
"""
|
|
2464
|
+
Contact Card lets you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing.
|
|
2465
|
+
|
|
2466
|
+
Use `POST /v3/contact_card` to create or update a card for a phone number.
|
|
2467
|
+
Use `PATCH /v3/contact_card` to update an existing active card.
|
|
2468
|
+
Use `GET /v3/contact_card` to retrieve the active card(s) for your partner account.
|
|
2469
|
+
|
|
2470
|
+
**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.
|
|
2471
|
+
"""
|
|
2472
|
+
from .resources.contact_card import AsyncContactCardResourceWithStreamingResponse
|
|
2473
|
+
|
|
2474
|
+
return AsyncContactCardResourceWithStreamingResponse(self._client.contact_card)
|
|
2475
|
+
|
|
2476
|
+
|
|
2477
|
+
Client = LinqAPIV3
|
|
2478
|
+
|
|
2479
|
+
AsyncClient = AsyncLinqAPIV3
|