# Guide to folk Webhooks: Features and Best Practices

folk webhooks tell your systems when people, companies, deals, notes and reminders change in your CRM workspace. If your team keeps its relationships in folk, webhooks are how those changes reach your data warehouse, enrichment pipeline or internal tools without polling the folk API.

This guide covers how folk webhooks are created and signed, the events and their thin payloads, how retries and deactivation work, and the practices that keep a folk receiver reliable.

## What are folk webhooks?

A folk webhook is a target URL plus a list of subscribed events. When one of those events happens in the workspace, folk sends a `POST` with a JSON event to the URL. Through the API you can also attach filters to each subscribed event, for example to hear only about people added to one group. The filters available differ by event type.

The payloads are deliberately small. Each one names the event and points at the affected record with an ID and an API URL. Updates also list what changed, and deletions carry a few details of the record that no longer exists. For anything else, you fetch the record from the folk API.

The webhook system follows the Standard Webhooks specification. folk signs every delivery itself, with `webhook-id`, `webhook-timestamp` and `webhook-signature` headers and a `whsec_` secret per webhook, and recommends the official Standard Webhooks libraries for verification.

## folk webhook features

| Feature | Details |
| --- | --- |
| Configuration | The workspace's API settings page, or `POST https://api.folk.app/v1/webhooks` |
| Subscription | A list of event types per webhook, with optional per-event filters through the API |
| Payload | `id`, `type`, `createdAt`, `source` and a `data` object pointing at the record |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, entries prefixed with `v1,` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | A new `whsec_` secret per webhook, visible only once at creation |
| Response window | `2xx` within 20 seconds |
| Retries | Immediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h |
| Deactivation | After the last retry fails, or at a high error rate. A `410` response deactivates the webhook at once |
| Ordering | Not guaranteed |

folk publishes no source IP list and names no timestamp tolerance.

## Common events

folk documents 20 event types across six resources.

| Resource | Events |
| --- | --- |
| People | `person.created`, `person.updated`, `person.deleted`, `person.groups_updated`, `person.workspace_interaction_metadata_updated` |
| Companies | `company.created`, `company.updated`, `company.deleted`, `company.groups_updated` |
| Objects (such as deals) | `object.created`, `object.updated`, `object.deleted` |
| Notes | `note.created`, `note.updated`, `note.deleted` |
| Reminders | `reminder.created`, `reminder.updated`, `reminder.deleted`, `reminder.triggered` |
| Enrichment | `person_enrichment.finished` |

`person.updated` and `company.updated` fire when native attributes or custom fields change. `person_enrichment.finished` fires once per enrichment, when its status becomes `completed` or `failed`, whether the enrichment was started from the API or the folk app.

Every event shares one envelope. Here is folk's `person.updated` example:

```json
{
  "id": "evt_6004f2ef-dcc3-4c56-a704-9326730ad100",
  "type": "person.updated",
  "createdAt": "2025-09-30T09:55:52.715Z",
  "source": "https://api.folk.app/v1/webhooks/wbk_2f15ec4f-0f70-4884-a43d-1f88ef7a6665",
  "data": {
    "id": "per_05837e2d-9bf7-4f17-8729-e95f1be4de67",
    "url": "https://api.folk.app/v1/people/per_05837e2d-9bf7-4f17-8729-e95f1be4de67",
    "changes": [{ "path": ["firstName"], "type": "set", "value": "John" }]
  }
}

```

* `id` is the event ID. It matches the `webhook-id` header and is what you deduplicate on.
* `type` is the event type. Route on it.
* `source` is the API URL of the webhook that delivered the event, which tells you which webhook fired when you have several.
* `data.url` is the API URL to fetch the full record.
* `data.changes` appears on updates, with each change as a `path`, a `type` (`add`, `remove` or `set`) and a `value`.
* `data.details` appears on deletions, because the record can no longer be fetched.

## Setting up folk webhooks

You can create a webhook in the app or through the API.

### In the folk app

1. Open your workspace's **API settings** page and create a new webhook.
2. Give it a name, enter the target URL, and choose the events to subscribe to.
3. Click **Create webhook** and copy the signing secret straight away. Store it server-side, for example as `FOLK_WEBHOOK_SECRET`.

### Through the API

```bash
curl -X POST "https://api.folk.app/v1/webhooks" \
  -H "Authorization: Bearer $FOLK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "targetUrl": "https://example.com/webhooks/folk",
    "subscribedEvents": [{ "eventType": "person.created" }, { "eventType": "person.updated" }]
  }'

```

The response includes `signingSecret`, a value such as `whsec_QWFSUzl1QUVoQW1kdWtpTnJRTUFpbXNlZmxLTg==`. folk shows it only once. If you lose it, you have to create a new webhook. Each webhook has its own secret, so a staging webhook's secret won't verify production deliveries.

If you use Hookdeck, create a source with the folk source type, paste the whole `whsec_` secret into it, and use the Hookdeck source URL as the webhook's target URL.

## Securing folk webhooks

To verify a delivery, join `webhook-id`, `webhook-timestamp` and the raw request body with dots. Take the part of the secret after `whsec_`, base64-decode it, and use the bytes as the HMAC-SHA256 key. Encode the result as base64 and compare it in constant time with each `v1,` entry in the space-delimited `webhook-signature` header. A match on any entry is enough.

The timestamp is signed, but folk sets no tolerance for it. The check is optional in the code below and off by default. If you turn it on, choose the window yourself, and remember that a delivery replayed later, for example from Hookdeck, keeps its original timestamp.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyFolkSignature(rawBody, headers, secret, toleranceSeconds = 0) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatureHeader = headers['webhook-signature'];
  if (!id || !timestamp || !signatureHeader || !secret || !secret.startsWith('whsec_')) return false;
  if (!/^\d+$/.test(timestamp)) return false;

  // Optional replay check, off by default.
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto
    .createHmac('sha256', key)
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatureHeader.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/folk', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyFolkSignature(req.body, req.headers, process.env.FOLK_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, queue the work, and fetch event.data.url later if you need the record.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hashlib
import hmac
import time

def verify_folk_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 0) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and signature_header and secret and secret.startswith("whsec_")):
        return False
    if not timestamp.isdigit():
        return False
    # Optional replay check, off by default.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False

    try:
        key = base64.b64decode(secret[len("whsec_"):])
    except (binascii.Error, ValueError):
        return False
    signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False

# In FastAPI: raw_body = await request.body()
# verify_folk_signature(raw_body, request.headers, os.environ["FOLK_WEBHOOK_SECRET"])

```

folk recommends the official `standardwebhooks` libraries, and they work. They always enforce a five-minute timestamp window, though, so a delivery that Hookdeck retries or replays more than five minutes after folk signed it fails the library's check even though its signature is valid. The [folk webhook skill](https://github.com/hookdeck/webhook-skills/tree/main/skills/folk-webhooks) includes a manual verifier where you can drop or widen that window.

> **Make folk webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the Standard Webhooks signature on every folk delivery, answers well inside the 20-second window, and keeps each CRM event until your service has processed it.

## folk webhook limitations and pain points

### A failing endpoint gets deactivated

**The Problem:** After an outage or a bad deploy, folk stops sending events altogether.

**Why It Happens:** When the last retry fails, or the error rate is high, folk deactivates the webhook and emails its owner. Returning `410 Gone` deactivates it immediately, without any retries. Events that happen while the webhook is inactive don't reach you.

**Workarounds:**

* Never return `410` from a webhook route, including from a framework's default handler for removed routes.
* Act on folk's deactivation email, fix the handler, and re-activate the webhook.
* After re-activating, reconcile by fetching recently changed records from the API.

**How Hookdeck Can Help:** Hookdeck accepts folk's deliveries while your service is down, so the error rate folk sees stays low and the webhook stays active. Events wait in Hookdeck until your service is back, and [retries](/docs/retries) follow a schedule you set.

### An unclear retry window

**The Problem:** You can't tell how long folk keeps retrying a failed event.

**Why It Happens:** folk's retry section says it retries "for up to one day", but its own schedule of waits (5 seconds, then 5 minutes, 30 minutes, 2, 5, 10, 14, 20 and 24 hours) adds up to about 75 hours.

**Workarounds:**

* Plan for the shorter figure and treat anything longer as a bonus.
* Reconcile through the API after any outage of more than a few hours.

**How Hookdeck Can Help:** Once events are in Hookdeck, how long they wait for your service no longer depends on folk's schedule.

### Thin payloads mean extra API calls

**The Problem:** Every event needs a follow-up request before you can act on it.

**Why It Happens:** `data` holds only the record's ID and API URL, plus the changes on an update. The full person, company or deal isn't in the body.

**Workarounds:**

* Use `data.changes` when it is enough, for example to react to one field.
* Fetch `data.url` in a background job rather than inside the request, so you stay under 20 seconds.

**How Hookdeck Can Help:** Hookdeck can deliver to your service at a rate you set, so a bulk import in folk turns into a steady stream of API fetches instead of a burst.

### Events arrive out of order

**The Problem:** A newer state is overwritten by an older one.

**Why It Happens:** folk doesn't guarantee ordering. Creating a deal can produce `object.created`, `person.updated` and `company.updated` in any order, and a retried event can arrive after a later one.

**Workarounds:**

* Fetch the current record from `data.url` rather than applying changes blindly.
* Compare `createdAt` with the timestamp you last stored for the record.

**How Hookdeck Can Help:** Hookdeck's event log shows the order events actually arrived in, which helps when you are tracking down an ordering bug.

## Best practices

### Verify against the raw body

Any change to the body, including re-serialising parsed JSON, breaks the signature. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after the check passes.

### Store the secret when you create the webhook

The `whsec_` secret is shown once. Put it in a secrets manager as soon as you create the webhook, and keep one secret per webhook and environment.

### Deduplicate on the event ID

Duplicates are possible. Record `id` (the same value as `webhook-id`) before processing and skip any you have already handled. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Subscribe narrowly and use filters

Subscribe only to the events you handle, and use the API's per-event filters to cut the volume further, for example to one group.

### Acknowledge fast and process asynchronously

Verify, store the event, return `200`, and do the API calls afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

folk webhooks are signed natively with Standard Webhooks and carry pointers rather than full records. Verify the signature against the raw body with the decoded `whsec_` key, deduplicate on the event `id`, fetch records from `data.url` without depending on arrival order, and keep the endpoint answering with a `2xx`, because a run of failures or a single `410` deactivates the webhook.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every folk signature, stores each event, and redelivers on your schedule, so an outage on your side doesn't get your webhook switched off.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle folk webhooks reliably in minutes.