Webhooks let you receive events from Productlane as they happen, without polling list endpoints. Every delivery is signed with HMAC-SHA256 so you can verify it came from us.
For the complete reference, including the full event catalogue and payload schemas, see the full webhooks guide.
How it works
You register a webhook with a URL, a label, and the resources you want to listen to.
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 before marking the delivery dead.
Manage webhooks under Settings → API → Webhooks. The v2 API can list them.
Subscribe to resources
You subscribe per resource, such as threads, messages, or contacts, and read the type field in your handler to see exactly what happened. A subscription covers every action on its resource.
Delivery payload
Every delivery is a POST with this body:
{
"id": "evt_3727ca7b4f0fff3c",
"type": "<the event that fired>",
"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: <the event that fired>
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 same id twice, drop the second.Productlane-Delivery-Idis unique per HTTP attempt and useful for matching against the delivery log in the dashboard.
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
A delivery moves through these states:
pending - queued for delivery, not attempted yet.
succeeded - we got a 2xx from your endpoint.
failed - non-2xx, network error, or timeout. A retry is scheduled.
dead - all attempts failed, or the webhook was deactivated mid-flight.
We make 3 attempts in total: the first delivery, a retry after 1 minute, and a last retry 5 minutes later.
The request timeout is 10 seconds; anything slower counts as a failure. After the last attempt the delivery moves to dead. After 5 dead deliveries in a row we email a warning, and after 10 we disable the webhook. The warning goes to every workspace Admin and to the person who created the webhook. You cannot choose recipients, so to limit who receives it, change teammates who do not work on webhooks from Admin to Member in Roles & Permissions.
The delivery log keeps 14 days of history. Open a webhook under Settings → API → Webhooks to inspect each delivery's status, payload, and response body.
Receiver checklist
Respond fast. Acknowledge with 2xx within 10 seconds and do the real work in a background job.
Read the body raw. Frameworks that auto-parse JSON usually destroy whitespace and break the signature. In Express, use
express.raw({ type: "application/json" })before your JSON middleware.Verify the signature, always. Don't rely on IP allowlists alone.
Deduplicate by
Productlane-Event-Id. Retries can re-deliver an event 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.
Local development
Expose your local server with a tunnel such as ngrok or Cloudflare Tunnel, register the tunnel URL as a webhook, and trigger events by acting in the dashboard, for example by creating a thread.
