
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.
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.
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.
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-Id is stable across retries. If you process the same id twice, drop the second.
Productlane-Delivery-Id is unique per HTTP attempt and useful for matching against the delivery log in the dashboard.
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.
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);
}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)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 delivery log keeps 14 days of history. Open a webhook under Settings → API → Webhooks to inspect each delivery's status, payload, and response body.
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.
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.