Skip to main content

Documentation Index

Fetch the complete documentation index at: https://productlane.mintlify.dev/docs/llms.txt

Use this file to discover all available pages before exploring further.

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

  1. You register a webhook with a url, a label, and a list of event patterns.
  2. We give you back a secret. Keep it - you’ll need it to verify deliveries.
  3. When an event happens, we POST a JSON payload to your URL.
  4. If your endpoint responds with 2xx, the delivery succeeds. Otherwise we retry on a schedule (1m, 5m, 30m, 2h, 6h, 24h) before marking the delivery dead.
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.
PatternCovers
thread.*thread.created, thread.updated, thread.deleted, thread.status_changed, thread.assigned, thread.tagged
message.*message.received (inbound), message.sent (outbound)
comment.*comment.created, comment.updated, comment.deleted
contact.*contact.created, contact.updated, contact.deleted
company.*company.created, company.updated, company.deleted
changelog.*changelog.created, changelog.published, changelog.updated, changelog.deleted
issue.*issue.created, issue.updated, issue.completed, issue.canceled, issue.deleted
project.*project.created, project.updated, project.completed, project.canceled, project.deleted
customer_need.*customer_need.created, customer_need.deleted (thread ↔ project/issue links)
doc.*doc.created, doc.updated, doc.deleted, doc.published
tag.*tag.created, tag.updated, tag.deleted
membership.*membership.invited, membership.joined, membership.role_changed, membership.removed
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-Id is stable across retries. If you process the same event_id twice, drop the second. (Most deliveries fire once, but retries can produce duplicates after network hiccups.)
  • Productlane-Delivery-Id is 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

StatusMeaning
pendingQueued for delivery, hasn’t been attempted yet.
succeededWe got a 2xx from your endpoint.
failedNon-2xx, network error, or your endpoint timed out. Retry scheduled.
deadAll 6 attempts failed, or the webhook was deactivated mid-flight.
Retry schedule (6 attempts total, including the first):
1m  →  5m  →  30m  →  2h  →  6h  →  24h
The 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 2xx within 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, and trigger events from the dashboard. Every webhook has a Send test event button that posts a sample payload of each event type you’ve subscribed to.