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 at Settings → Integrations → API → Webhooks or via the API.
You subscribe at the resource level (threads, messages, contacts, and so on), then switch on the type field in your handler to see exactly what happened. Subscribing to a whole resource means you never silently miss part of its activity.
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 total, including the first, on this schedule:
1m -> 5m -> 30mThe request timeout is 10 seconds; anything slower counts as a failure. After the final attempt the delivery moves to dead. We keep the delivery log for 30 days at Settings → Integrations → API → Webhooks → [webhook] → Deliveries, where you can inspect the response body, status, and headers, and replay the delivery manually.
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.
Use a tunnel (ngrok, Cloudflare Tunnel) to expose your local server, register the tunnel URL as a webhook, and trigger events from the dashboard. Every webhook has a Send test event button that posts a sample payload.