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

# Set up a webhook

> Connect a destination, add a rule, verify the signature.

Get a signed event into your own endpoint in three steps.

**1. Connect a destination**

```bash theme={null}
curl -X POST https://api.ziplined.io/v1/integrations \
  -H "Authorization: Bearer $ZIPLINED_API_KEY" \
  -H "Idempotency-Key: 8c2e1a70-1d3b-4c2a-9f10-4a7b2c5d6e8f" \
  -H "Content-Type: application/json" \
  -d '{
        "provider": "webhook",
        "config": { "url": "https://hooks.example.com/ziplined" }
      }'
```

Before storing anything, we send a signed `ping` to your URL. It must answer 2xx within
ten seconds. Make the endpoint acknowledge a POST it does not recognise before you run
this.

```json theme={null}
{
  "data": {
    "secret": "whsec_8Yq2...",
    "integration": {
      "id": "5f0b...",
      "provider": "webhook",
      "status": "active",
      "config_summary": { "host": "hooks.example.com" },
      "created_at": "2026-09-13T09:00:00.000Z"
    }
  }
}
```

> [!WARNING]
>
> The `secret` is shown exactly once, in this response. It is never readable again, not
> through `GET /v1/integrations` and not in a replayed answer to the same
> `Idempotency-Key`. Store it now.

> [accordion] One destination per provider
>
> A workspace has one destination per provider. Posting a second one updates the first.
>
> A URL that does not answer is a 422 `endpoint_unreachable`, and no destination is
> created.

**2. Add a routing rule**

```bash theme={null}
curl -X POST https://api.ziplined.io/v1/integrations/5f0b.../rules \
  -H "Authorization: Bearer $ZIPLINED_API_KEY" \
  -H "Idempotency-Key: 2b3c4d5e-6f70-4a8b-9c0d-2e3f4a5b6c7d" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "warmNlEngagement",
        "event": "new_engagement",
        "audience_id": null,
        "filter": {
          "min_warmth_tier": "warm",
          "engagement_type": ["comment"],
          "first_engagement_only": true
        },
        "min_interval_minutes": 0
      }'
```

A rule picks one event, narrows it with a filter, and sets how often it may fire. The
events and filter keys are on [Events](https://docs.ziplined.io/webhooks/events.md).

`name` is the stable key of the rule inside the destination. Sending the same name again
updates that rule and leaves its cursor alone.

**3. Verify the signature**

```javascript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

/** `body` must be the raw request body, not a re-serialised object. */
export function verify(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signature = headers["webhook-signature"];

  // Reject anything more than five minutes old.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected =
    "v1," +
    createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

This follows the [Standard Webhooks](https://www.standardwebhooks.com) spec. An existing
verify function in Make, n8n or Clay works.

Answer 2xx as soon as you have stored the event. We wait ten seconds and then treat the
attempt as failed.

## The payload

One POST per delivery, never a batch.

```http theme={null}
POST /ziplined HTTP/1.1
content-type: application/json
webhook-id: 0f3a8e2c-7b41-4b8f-9a1c-2d5e6f708192
webhook-timestamp: 1789344000
webhook-signature: v1,K5s9C2r...
```

```json theme={null}
{
  "id": "0f3a8e2c-7b41-4b8f-9a1c-2d5e6f708192",
  "type": "lead.qualified",
  "timestamp": "2026-09-13T09:20:00.000Z",
  "workspace_id": "0a1b...",
  "data": { "event": "new_qualified_lead", "person_id": "7b1f...", "...": "..." }
}
```

`id` is the delivery id and does not change across retries. Use it as your idempotency key.

> [accordion] What timestamp and data mean
>
> `timestamp` is when we sent this attempt, not when the fact happened. That moment is inside
> `data`.
>
> `data` is frozen as it was composed when the fact occurred. That is why the ledger can still
> tell you what you were told months ago.

## Test it later

```bash theme={null}
curl -X POST https://api.ziplined.io/v1/integrations/5f0b.../test \
  -H "Authorization: Bearer $ZIPLINED_API_KEY"
```

You get back what the receiver said: `{ ok, status, body, error }`. Nothing is written to
the ledger.

> [accordion] Why a refusal is a 200
>
> A receiver that refuses is `ok: false` inside a 200. The test ran, and its answer was no.
>
> There is no `Idempotency-Key` here, since pressing test twice has to send two events.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.