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_ |
| 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_ |
person.updated and company.updated fire when native attributes or custom fields change. person_ 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" }]
}
}
idis the event ID. It matches thewebhook-idheader and is what you deduplicate on.typeis the event type. Route on it.sourceis the API URL of the webhook that delivered the event, which tells you which webhook fired when you have several.data.urlis the API URL to fetch the full record.data.changesappears on updates, with each change as apath, atype(add,removeorset) and avalue.data.detailsappears 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
- Open your workspace's API settings page and create a new webhook.
- Give it a name, enter the target URL, and choose the events to subscribe to.
- 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_. 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
410from 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.changeswhen it is enough, for example to react to one field. - Fetch
data.urlin 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.urlrather than applying changes blindly. - Compare
createdAtwith 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.