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

# Webhooks

> Create endpoints, verify the X-PickFu-Signature HMAC, and react to survey.completed and order.paid events.

v2 delivers events to HTTPS endpoints you register with `POST /v2/webhook-endpoints`. Deliveries are signed with an HMAC secret so you can verify they came from PickFu.

## Managing endpoints

The `/v2/webhook-endpoints` resource is a standard CRUD collection under the `write` scope for mutations and `read` for reads.

<CardGroup cols={2}>
  <Card title="Create" icon="plus">
    `POST /v2/webhook-endpoints` with `{ url, enabledEvents }` returns the endpoint **and the signing secret** — this is the only time the secret is returned. Store it before you exit the response.
  </Card>

  <Card title="List and read" icon="list">
    `GET /v2/webhook-endpoints` (cursor-paginated) and `GET /v2/webhook-endpoints/{id}`. Neither includes the signing secret.
  </Card>

  <Card title="Update" icon="pen">
    `PATCH /v2/webhook-endpoints/{id}` changes the URL or the subscribed events.
  </Card>

  <Card title="Delete" icon="trash">
    `DELETE /v2/webhook-endpoints/{id}` stops all deliveries to that endpoint.
  </Card>
</CardGroup>

Endpoint IDs are prefixed `whk_…`. Each team may register up to 10 endpoints.

```bash theme={null}
curl -X POST https://api.pickfu.com/v2/webhook-endpoints \
  -H "Authorization: Bearer sk_live_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/pickfu/webhooks",
    "enabledEvents": ["survey.completed", "order.paid"]
  }'
```

```json theme={null}
{
  "object": "webhook_endpoint",
  "id": "whk_01J9C2VZH8...",
  "url": "https://your-app.example.com/pickfu/webhooks",
  "enabledEvents": ["survey.completed", "order.paid"],
  "secret": "whsec_...",
  "createdAt": "2026-09-10T18:22:00.000Z",
  "updatedAt": "2026-09-10T18:22:00.000Z"
}
```

## Events

| Event | When it fires | `data.object` |
| - | - | - |
| `survey.completed` | A published survey reaches `status: "completed"`. | The survey resource. |
| `order.paid` | An order transitions to `paid` — either a credit-pack purchase confirmed by Stripe, or a survey order paid at publish. | The order resource. |

## Delivery envelope

Every delivery has the same shape:

```json theme={null}
{
  "id": "evt_01J9C2VZH8...",
  "type": "survey.completed",
  "createdAt": "2026-09-10T18:25:00.000Z",
  "data": {
    "object": { /* full survey or order resource */ }
  }
}
```

`id` is stable across retries of the same occurrence — use it to dedupe. Reply with any `2xx` status within your timeout to acknowledge; a non-2xx or timeout is retried.

## Verifying the signature

Every delivery carries an `X-PickFu-Signature` header:

```
X-PickFu-Signature: t=1725974400,v1=5d4b2c...
```

* `t` is the unix timestamp (seconds) the delivery was signed.
* `v1` is the hex-encoded **HMAC-SHA256** of `<t>.<raw-body>` using the signing secret returned when you created the endpoint.

Verify every delivery:

1. Reject requests missing `X-PickFu-Signature`.
2. Reject requests whose `t` is more than a few minutes off the current time (protects against replay).
3. Recompute `HMAC_SHA256(secret, `${t}.${rawBody}`)` and compare to `v1` with a **constant-time** comparison.

### Node.js example

```ts theme={null}
import crypto from "node:crypto";

function verifyPickFuSignature(header: string, rawBody: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("="))
  );
  const t = parts["t"];
  const v1 = parts["v1"];
  if (!t || !v1) return false;

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(t));
  if (ageSeconds > 5 * 60) return false;

  // Reject anything that isn't a 64-char hex digest before comparing —
  // timingSafeEqual throws on length mismatch, which would surface an
  // attacker-controlled header value as a request-triggered error.
  if (!/^[0-9a-f]{64}$/i.test(v1)) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

<Warning>
  Verify against the **raw request body** (bytes on the wire), not the parsed JSON. Re-serialising the parsed body will change whitespace, key order, or number formatting and break the signature.
</Warning>

## Retries and ordering

* **Retries.** Non-2xx responses and timeouts are retried with backoff. The envelope `id` stays the same across retries, so idempotent processing keys off `id`.
* **Ordering.** Deliveries are best-effort in-order per endpoint but not strictly ordered — always trust `createdAt` and the current resource state in `data.object` over delivery order.
* **Rotating the secret.** Delete the endpoint and create a new one. The signing secret is only ever returned at creation time.


## Related topics

- [PickFu CLI for surveys and automation](/docs/integrations/cli.md)
- [PickFu API v2](/docs/api-reference/v2/introduction.md)
- [Idempotency](/docs/api-reference/v2/idempotency.md)
- [How to build PickFu surveys with URL parameters](/help/how-to-build-pickfu-surveys-with-url-parameters.md)
- [Authentication](/docs/api-reference/v2/authentication.md)


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