Webhooks
Real-time event notifications for messages, reactions, chats, and more.
Webhook Subscriptions allow you to receive real-time notifications when events occur on your account.
Configure webhook endpoints to receive events such as messages sent/received, delivery status changes, reactions, typing indicators, and more.
Failed deliveries (5xx, 429, network errors) are retried with exponential backoff for up to 30 minutes. Each event includes a unique ID for deduplication.
Webhook Headers
Section titled “Webhook Headers”All webhook requests include two sets of headers. If you have an existing integration
using the X-Webhook-* headers, nothing changes — those headers are still sent on
every delivery and work exactly as before. The new webhook-* headers follow the
Standard Webhooks specification.
You can safely ignore them if your current verification code works and you don’t want to use this convention.
Standard Webhooks Headers (Recommended)
Section titled “Standard Webhooks Headers (Recommended)”Used by our SDK and any Standard Webhooks library.
| Header | Description |
|---|---|
webhook-id | Unique event identifier (use as idempotency key) |
webhook-timestamp | Unix timestamp (seconds) when the webhook was sent |
webhook-signature | Standard Webhooks signature (v1,{base64} format) |
Legacy Headers (Deprecated)
Section titled “Legacy Headers (Deprecated)”Still sent on every delivery for backwards compatibility. Existing verification code using these headers continues to work — no changes required.
| Header | Description |
|---|---|
X-Webhook-Event | (deprecated) Event type (e.g., message.sent) |
X-Webhook-Subscription-ID | (deprecated) Webhook subscription ID |
X-Webhook-Timestamp | (deprecated) Unix timestamp (seconds) |
X-Webhook-Signature | (deprecated) HMAC-SHA256 signature (hex-encoded) |
Signing Secrets
Section titled “Signing Secrets”Signing secrets use the Standard Webhooks format: a whsec_ prefix followed
by base64-encoded random bytes (e.g., whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw7Jxx2Oll+OE=).
Strip the whsec_ prefix and base64-decode the remainder to get the raw key bytes.
Verifying Webhook Signatures
Section titled “Verifying Webhook Signatures”Webhooks are signed following the Standard Webhooks specification. You can use any Standard Webhooks library to verify signatures, or implement verification manually:
Signed content: {webhook-id}.{webhook-timestamp}.{body}
Verification Steps:
- Extract the
webhook-id,webhook-timestamp, andwebhook-signatureheaders - Reject if the timestamp is more than 5 minutes old (replay protection)
- Get the raw request body bytes (do not parse and re-serialize)
- Construct signed content:
"{webhook-id}.{webhook-timestamp}.{body}" - Strip the
whsec_prefix from your secret and base64-decode to get key bytes - Compute HMAC-SHA256 using the key bytes over the signed content
- Base64-encode the result and compare with the value after
v1,inwebhook-signature - Use constant-time comparison to prevent timing attacks
Example (Python):
import base64, hmac, hashlib
def verify_webhook(secret, body, headers):
msg_id = headers['webhook-id']
timestamp = headers['webhook-timestamp']
signature = headers['webhook-signature']
secret_str = secret.removeprefix('whsec_')
key = base64.b64decode(secret_str)
signed_content = f"{msg_id}.{timestamp}.{body}"
expected = base64.b64encode(
hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
).decode()
for sig in signature.split(' '):
if sig.startswith('v1,') and hmac.compare_digest(expected, sig[3:]):
return True
return False
Example (Node.js):
const crypto = require('crypto');
function verifyWebhook(secret, rawBody, headers) {
const msgId = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
const secretStr = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const keyBytes = Buffer.from(secretStr, 'base64');
const signedContent = `${msgId}.${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', keyBytes)
.update(signedContent)
.digest('base64');
return signature.split(' ').some(sig => {
if (!sig.startsWith('v1,')) return false;
try {
return crypto.timingSafeEqual(
Buffer.from(expected, 'base64'),
Buffer.from(sig.slice(3), 'base64')
);
} catch { return false; }
});
}
Security Best Practices:
- Reject webhooks with timestamps older than 5 minutes to prevent replay attacks
- Always use constant-time comparison for signature verification
- Store your signing secret securely (e.g., environment variable, secrets manager)
- Return a 2xx status code quickly, then process the webhook asynchronously
Setting up webhooks
Section titled “Setting up webhooks”Create a webhook subscription to start receiving events. Each subscription targets a URL you own, filters to the events you care about, and returns a signing secret you use to verify inbound requests. See Webhook Subscriptions for the full subscription lifecycle — create, list, retrieve, update, delete, and phone-number filtering.
Webhook versioning
Section titled “Webhook versioning”Webhook payloads are versioned using dates. Specify a version by adding ?version=YYYY-MM-DD to your subscription URL:
https://your-server.com/webhook?version=2026-02-03| Subscription created | Webhook version |
|---|---|
| Before 2026-02-03 | 2025-01-01 |
| 2026-02-03 or later | 2026-02-03 |
If no version is specified, the subscription uses the latest available version at creation time.
Tip: Always specify a version explicitly to avoid unexpected payload format changes.
Verifying signatures with an SDK
Section titled “Verifying signatures with an SDK”The Verifying Webhook Signatures section above describes the Standard Webhooks signing scheme and how to verify it manually. If you use one of our SDKs, you don’t have to: webhooks.unwrap() does it for you — it checks the signature headers, throws if the signature is invalid, and returns the typed, discriminated event. Provide your signing secret via the LINQ_WEBHOOK_SECRET environment variable (or pass it to the client). Always pass the raw request body — not parsed JSON — so the signature matches.
import LinqAPIV3 from '@linqapp/sdk';
// webhookSecret defaults to process.env.LINQ_WEBHOOK_SECRETconst client = new LinqAPIV3();
// In your webhook handler:const event = client.webhooks.unwrap(rawBody, { headers: req.headers });// Throws on an invalid signature. `event` is fully typed.from linq import LinqAPIV3
# webhook_secret defaults to os.environ["LINQ_WEBHOOK_SECRET"]client = LinqAPIV3()
# In your webhook handler:event = client.webhooks.unwrap(raw_body, headers=request.headers)# Raises on an invalid signature. `event` is fully typed.import "github.com/linq-team/linq-go" // imported as linqgo
// reads LINQ_WEBHOOK_SECRET from the environmentclient := linqgo.NewClient()
// In your webhook handler:event, err := client.Webhooks.Unwrap(rawBody, req.Header)// err is non-nil on an invalid signature. event is the typed payload union.Delivery guarantees
Section titled “Delivery guarantees”| Guarantee | Value |
|---|---|
| Response timeout | 10 seconds |
| Retry attempts | About 10, until the retry window ends |
| Retry backoff | Exponential with jitter, capped at 10 minutes |
| Total retry window | 30 minutes from when the event was created |
| Delivery model | At-least-once (duplicates possible) |
Retried: HTTP 5xx, HTTP 429, connection timeout, connection refused, redirect loops, 3xx without a Location header.
Not retried: HTTP 4xx (except 429), DNS failures, invalid hostnames, redirects from https:// to http://.
Your endpoint should:
- Return
200quickly — process asynchronously if needed - Verify the signature using your subscription’s signing secret
- Deduplicate using
event_id - Be idempotent
Troubleshooting redirects (HTTP 3xx)
Section titled “Troubleshooting redirects (HTTP 3xx)”A 3xx response means your server is redirecting the delivery to another URL. Don’t rely on redirects: register the final URL, the one that answers 2xx directly, as your target_url.
If your endpoint does redirect, this is what happens:
301,302,303: The follow-up is sent as aGETwith no body. If your endpoint answers thatGETwith2xx, the delivery counts as successful but you never receive the event. A4xx(commonly404or405) is a permanent failure and is not retried.307,308: ThePOSTand body are re-sent. Every hop counts against the 10-second response timeout.https://→http://: Rejected. Linq never downgrades a delivery to plain HTTP, and the attempt is not retried.- A chain of 10 redirects fails and is retried.
- A
3xxwith noLocationheader fails and is retried.
Common causes:
- Adding or removing
www. You registeredhttps://example.com/webhooks/and your server redirects tohttps://www.example.com/webhooks/(or the reverse). - Adding or removing a trailing slash. You registered
https://example.com/webhooks/and your server redirects tohttps://example.com/webhooks(or the reverse). - Moved endpoints. A load balancer, proxy, or hosting platform redirects an old path or domain to a new one.
To fix it, update the subscription and set target_url to the final URL after redirects. To find that URL, send a test POST and follow the Location header:
curl -sS -o /dev/null -X POST -L -w '%{http_code} %{url_effective}\n' https://example.com/webhooks/url_effective is the URL to register. It must be https://. Keep any query parameters, such as ?version=, on the new URL.
Related
Section titled “Related”- Webhook Subscriptions — create, list, update, delete subscriptions
- Webhook Events — full event list, shared envelope, and representative payloads, including account events
- API Reference: Webhook Events
- API Reference: Webhook Subscriptions