Gareth Wilson Gareth Wilson

Guide to folk Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe workspace's API settings page, or POST https://api.folk.app/v1/webhooks
SubscriptionA list of event types per webhook, with optional per-event filters through the API
Payloadid, type, createdAt, source and a data object pointing at the record
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeStandard Webhooks: HMAC-SHA256, base64, entries prefixed with v1,
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
SecretA new whsec_ secret per webhook, visible only once at creation
Response window2xx within 20 seconds
RetriesImmediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h
DeactivationAfter the last retry fails, or at a high error rate. A 410 response deactivates the webhook at once
OrderingNot guaranteed

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

Common events

folk documents 20 event types across six resources.

ResourceEvents
Peopleperson.created, person.updated, person.deleted, person.groups_updated, person.workspace_interaction_metadata_updated
Companiescompany.created, company.updated, company.deleted, company.groups_updated
Objects (such as deals)object.created, object.updated, object.deleted
Notesnote.created, note.updated, note.deleted
Remindersreminder.created, reminder.updated, reminder.deleted, reminder.triggered
Enrichmentperson_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:

{
  "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

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.

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:

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 includes a manual verifier where you can drop or widen that window.

Make folk webhooks production-ready. Hookdeck 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 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.

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.

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 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 for free and handle folk webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.