> ## Documentation Index
> Fetch the complete documentation index at: https://productlane.mintlify.site/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Subscribe to resource events instead of polling. HMAC-signed, retried with backoff.

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) 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.

| Pattern           | Covers                                                                                                            |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| `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:

```json theme={null}
{
  "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

```typescript theme={null}
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

```python theme={null}
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                                                              |
| ----------- | -------------------------------------------------------------------- |
| `pending`   | Queued for delivery, hasn't been attempted yet.                      |
| `succeeded` | We got a `2xx` from your endpoint.                                   |
| `failed`    | Non-2xx, network error, or your endpoint timed out. Retry scheduled. |
| `dead`      | All 3 attempts failed, or the webhook was deactivated mid-flight.    |

Retry schedule (3 attempts total, including the first):

```
1m  →  5m  →  30m
```

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.
