Webhooks let you receive events from Productlane as they happen - new threads, status changes, comments, contacts, changelogs, and more - without polling list endpoints. Every delivery is signed with HMAC-SHA256 so you can verify it came from us.
How it works
You register a webhook with a
url, alabel, and a list of event patterns.We give you back a
secret. Keep it - you'll need it to verify deliveries.When an event happens, we POST a JSON payload to your URL.
If your endpoint responds with
2xx, the delivery succeeds. Otherwise we retry on a schedule (1m, 5m, 30m) before marking the deliverydead.
Manage webhooks at Settings → Integrations → API → Webhooks or via the API.
Subscribe to events
The picker exposes 12 resource-level patterns. Subscribe to the resource, then switch on the type field at delivery time.
Pattern | Covers |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
We deliberately don't let you subscribe to individual event types. Too many integrations break when they subscribe to thread.created and silently miss thread.assigned. Subscribe to the resource, switch on type in your handler.
Delivery payload
Every delivery is a POST with this body:
{
"id": "evt_3727ca7b4f0fff3c",
"type": "thread.status_changed",
"created_at": "2026-05-12T10:23:45.123Z",
"data": {
/* the resource snapshot at the time of the event */
}
}And these headers:
Content-Type: application/json
User-Agent: Productlane-Webhooks/1.0
Productlane-Event: thread.status_changed
Productlane-Event-Id: evt_3727ca7b4f0fff3c
Productlane-Delivery-Id: wdl_a1b2c3
Productlane-Webhook-Id: wbk_xyz789
Productlane-Signature: t=1746489600,v1=<hex hmac sha256>Productlane-Event-Idis stable across retries. If you process the sameevent_idtwice, drop the second. (Most deliveries fire once, but retries can produce duplicates after network hiccups.)Productlane-Delivery-Idis unique per HTTP attempt and useful for matching against the delivery log in the dashboard.
Custom channels
Threads created with custom_channel (for example whatsapp or sms) have origin: "custom". Productlane delivers nothing on these threads. Every outbound reply, whether written by a teammate, the AI agent, an auto-reply or a close-the-loop update, fires message.sent with the full body so your integration can send it:
{
"thread_id": "thr_123",
"message_id": "msg_456",
"channel": "custom",
"custom_channel": "whatsapp",
"author_type": "user",
"user_id": "usr_789",
"html": "<p>Thanks, we fixed it.</p>",
"text": "Thanks, we fixed it.",
"attachments": [],
"contact": { "id": "con_1", "email": null, "external_ids": ["+4915112345678"] },
"thread_external_ids": []
}author_type is one of user, ai, auto_reply or close_loop. contact.email is null for contacts created with only contact_external_id.
Post the customer's inbound messages with POST /threads/{id}/messages, author: "contact" and your own external_message_id. A retry with the same external_message_id returns the stored message with deduplicated: true and creates nothing.
Verify the signature
The signature is HMAC-SHA256 over ${timestamp}.${rawBody}, using your webhook's secret as the key.
Always read the request body raw before parsing it as JSON. Re-serializing reorders keys and breaks the signature.
Node.js
import crypto from "node:crypto";
export function verifyWebhook(
rawBody: string,
header: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=", 2) as [string, string]),
);
const t = parts.t;
const v1 = parts.v1;
if (!t || !v1) return false;
// Reject replays older than 5 minutes
const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (ageSeconds > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(v1, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hmac
import hashlib
import time
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
if abs(int(time.time()) - int(t)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{t}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(v1, expected)Retries and delivery state
Status | Meaning |
|---|---|
| Queued for delivery, hasn't been attempted yet. |
| We got a |
| Non-2xx, network error, or your endpoint timed out. Retry scheduled. |
| All 3 attempts failed, or the webhook was deactivated mid-flight. |
Retry schedule (3 attempts total, including the first):
1m → 5m → 30mThe request timeout is 10 seconds. Anything slower counts as a failure.
After the final failure, the delivery moves to dead. We keep the delivery log for 30 days at Settings → Integrations → API → Webhooks → [webhook] → Deliveries - you can inspect the response body, status, headers, and replay it manually from there.
Receiver checklist
Things receivers commonly get wrong, in order of how often they bite us in support:
Respond fast. Acknowledge with
2xxwithin 10 seconds. Do the actual work in a background job.Read the body raw. Frameworks that auto-parse JSON usually destroy whitespace and break the signature. Express:
express.raw({ type: "application/json" })before your JSON middleware.Verify the signature. Always. Don't trust IP allowlists alone.
Deduplicate by
Productlane-Event-Id. Retries can re-deliver successful events if your endpoint replied slowly the first time.Reject stale deliveries. If the signature timestamp is older than 5 minutes, return
400- it's an attempted replay.Return 410 for permanently-gone endpoints. We don't auto-disable webhooks based on status codes (yet), but the dashboard surfaces 410s prominently.
Local development
Use a tunnel (ngrok, Cloudflare tunnel) to expose your local server, register the tunnel URL as a webhook, then perform the corresponding action in Productlane (create a thread, add a comment, etc.) to fire real events at your endpoint.
