Outgoing webhooks let a client's own systems hear what happens in their account: a CRM that
gets every website form, a bookkeeping tool that hears when a lead is won, a Slack channel that
announces a published post. The platform sends a signed POST with JSON to an address you
choose, retries when it fails, and keeps a log.
These are not the publish webhook of a site (Webhook bij publiceren under Website → Instellingen → Ontwikkeling), which tells your site's code to purge its cache; that one is covered in Live preview and revalidation. Outgoing webhooks are per client, for any system, and carry business events.
Setting one up
Webhooks are managed per client under Instellingen → Webhooks (Settings → Webhooks): open the client, go to Instellingen, and click Webhooks beheren (manage webhooks).
Who sees this: people with the permission workspace.webhooks, which is part of the client's
settings area at the Beheren (manage) level. The agency team always has it; whether clients
get it is up to the agency's access policy.
- Click Webhook toevoegen (add webhook).
- Fill in Adres (URL): the https address of the receiving system.
- Optionally give it a description (placeholder Bv. Leads naar Teamleader).
- Tick the Gebeurtenissen (events) you want, at least one.
- Click Webhook toevoegen. The dialog shows Je geheime sleutel (your secret key) once: copy it with Sleutel kopiëren into the receiving system if it verifies signatures, then click Klaar (done).
The secret starts with whsec_. It is stored encrypted and never shown again; to get a new one,
delete the webhook and add it again. A client can have at most 10 webhooks.
Each webhook in the list has Test versturen (send test), Pauzeren / Weer aanzetten (pause / turn back on) and Verwijderen (delete). The address and the events of an existing webhook are not editable; add a new one and delete the old.
Events
| Event key | Label in the dashboard | Sent when |
|---|---|---|
form.submitted | Een formulier werd verstuurd | A visitor sent a form on a production site. Never for spam, never for staging sites. A message rescued from spam (Geen spam) is sent then. |
lead.updated | Een aanvraag kreeg een nieuwe status of waarde | A submission's follow-up status (Opvolging), result or value changed, in Formulieren or through an app (apps API). |
request.created | Een nieuw verzoek | A request between client and agency was created, by either side. |
request.replied | Een antwoord op een verzoek | Someone replied to a request, either side. |
post.published | Een post staat online | A social post is online on every platform it was meant for, published automatically or marked by hand. Once per post. |
page.published | Een pagina werd gepubliceerd | A page was published and is online now (not for a page that goes online later), never for staging sites. |
There is one more event you cannot subscribe to: ping, sent by Test versturen.
Event keys are a public contract: they are added, never renamed (webhookEvents in
@kwisple/core).
The request
POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: Kwisple-Webhooks/1.0
x-webhook-id: evt_4f9c0d2b7a1e6c3d5b8a9f01
x-webhook-event: form.submitted
x-webhook-signature: t=1793360000,v1=5d41402abc4b2a76b9719d911017c592…
{"id":"evt_4f9c0d2b7a1e6c3d5b8a9f01","event":"form.submitted","occurredAt":"2026-10-30T09:12:44.120Z","workspace":{"id":"…","name":"RANSON.N"},"data":{…}}| Header | Value |
|---|---|
x-webhook-id | The event id (evt_ + 24 hex characters). The same on every retry: use it to drop duplicates. |
x-webhook-event | The event key, so you can route before parsing the body. |
x-webhook-signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, see Verifying the signature. |
The envelope
Every body has the same outer shape:
| Field | Type | Meaning |
|---|---|---|
id | string | Event id, equal to x-webhook-id. |
event | string | Event key. |
occurredAt | string | ISO time the event happened (not the time of this attempt). |
workspace | { id, name } | The client. |
data | object | The event's payload, below. |
Payloads
url fields link into the dashboard, for a person to open.
form.submitted
{
"submission": {
"id": "…",
"form": "Contactformulier",
"fields": { "naam": "Lotte", "email": "lotte@example.com", "bericht": "…" },
"labels": { "naam": "Naam", "email": "E-mail", "bericht": "Bericht" },
"createdAt": "2026-10-30T09:12:44.000Z"
},
"site": { "id": "…", "name": "Website", "url": "https://example.be", "environment": "production" },
"url": "https://<dashboard>/w/<client>/website/<site>/formulieren"
}fields holds what the visitor typed, keyed by field name (strings and booleans). labels maps
those names to their labels when the form is declared in the content model (defineForm);
otherwise it can be empty. form is the form's name, or its handle.
lead.updated
{
"submission": { "id": "…", "status": "accepted", "outcome": "won", "value": 1250, "currency": "EUR" },
"url": "https://<dashboard>/w/<client>/website/<site>/formulieren"
}status is one of new, waiting, accepted, declined, done. outcome is the result
set in the inbox: won (Klant geworden), lost (Geen klant geworden) or an empty
string. value is the amount in euros, set only for won, else null.
request.created and request.replied
{
"request": { "id": "…", "title": "Nieuwe foto's op de homepage" },
"message": { "body": "…", "side": "client", "author": "Lotte Peeters" },
"url": "https://<dashboard>/w/<client>/verzoeken/<id>"
}side is client or agency: who wrote this message.
post.published
{
"post": {
"id": "…",
"title": "Opendeurdag",
"publishedAt": "2026-10-30T10:00:00.000Z",
"platforms": [{ "platform": "instagram", "url": "https://…" }, { "platform": "linkedin", "url": null }]
},
"url": "https://<dashboard>/w/<client>/social"
}page.published
{
"page": { "id": "…", "title": "Over ons", "urls": ["https://example.be/over-ons"] },
"site": { "id": "…", "name": "Website", "url": "https://example.be", "environment": "production" }
}urls lists the page's public addresses on the site.
ping (from Test versturen): { "message": "Test" }.
Verifying the signature
v1 is the hex HMAC-SHA256 of the string "<t>.<raw body>", keyed with the webhook's secret
(whsec_…). Verify it on the raw body exactly as received; parsing and re-serialising the
JSON changes the bytes. Reject old timestamps to stop replays.
import { createHmac, timingSafeEqual } from "node:crypto";
/** True when the request really came from the platform and is less than five minutes old. */
export function verifyWebhook(
rawBody: string,
header: string | null,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
(header ?? "").split(",").map((p) => p.split("=", 2) as [string, string]),
);
const t = Number(parts.t);
const v1 = parts.v1 ?? "";
if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(v1)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}In a Next.js route handler:
export async function POST(request: Request) {
const raw = await request.text();
if (!verifyWebhook(raw, request.headers.get("x-webhook-signature"), process.env.CRM_WEBHOOK_SECRET ?? ""))
return new Response(null, { status: 401 });
const event = JSON.parse(raw) as { id: string; event: string; data: unknown };
// Already handled this id? Answer 2xx and stop: it is a retry.
// … handle event.event / event.data
return new Response(null, { status: 204 });
}(CRM_WEBHOOK_SECRET is your receiver's own variable name; the platform does not prescribe
one.)
t is set per attempt, so a retry carries a fresh timestamp and a fresh signature over the
same body.
Delivery and retries
- Nothing waits for you. Emitting an event only queues a job; the worker sends it. A dashboard action never waits on your server, and an event is only built when at least one active webhook of the client listens to it.
- Success is any
2xxanswer. The response body is never read. Everything else is a failure: another status (including redirects, which are never followed), no answer within 10 seconds, or no connection. - Retries: a failed delivery is retried with exponential backoff, starting at 30 seconds and doubling up to one hour between attempts, up to 10 retries (several hours in all).
- No duplicates from the platform's side: one delivery per event per webhook
(
singletonKey<eventId>:<endpointId>), even when the fan-out itself is retried. Your receiver can still see the samex-webhook-idtwice (a retry after a slow2xx), so deduplicate on it. - No ordering guarantee. Deliveries run in parallel (five at a time per worker) and retries
arrive later. Use
occurredAtwhen order matters. - Queue jobs and their payloads are deleted a day after they finish.
Pausing after failures
The failure counter counts events, not attempts: one event that fails ten times counts once, and any successful delivery resets the counter to zero. After 25 failed events in a row the webhook is paused automatically and shows Gepauzeerd na te veel fouten (paused after too many errors). While a webhook is paused, its events are not delivered and not queued for it later.
Fix the receiver, then click Weer aanzetten, which also resets the counter. Pauzeren pauses a webhook by hand (shown as Gepauzeerd).
Test versturen
Test versturen sends a ping straight away, without retries, and reports the result:
Testbericht aangekomen (HTTP 200). or Testbericht niet aangekomen: … with the reason. On a
paused webhook it does not send and says Deze webhook staat op pauze. Zet hem eerst weer aan en
verstuur dan een test. A test is logged like any delivery, and a failed test counts toward the
failure counter.
The delivery log
Each webhook shows a badge (Laatst aangekomen …, … keer mislukt or Nog niets
verstuurd) and its five most recent attempts of the last seven days: time, event and result.
Results are HTTP <status> or one of:
| Shown | Code | Meaning |
|---|---|---|
| geen antwoord binnen 10 seconden | timeout | No response within 10 s. |
| geen verbinding | connection | DNS, TLS or connection failure. |
| adres niet toegelaten | unsafe | The address failed the outbound check (below). |
| gepauzeerd | paused | Test on a paused webhook. |
| verwijderd | removed | The webhook was deleted before the delivery ran. |
Attempts are kept for 30 days.
Address rules
Every address goes through the platform's outbound URL guard, when the webhook is added and again on every delivery:
- Production: public
httpsaddresses only.httpand private, loopback or link-local addresses (such aslocalhost,10.0.0.0/8,192.168.0.0/16,169.254.0.0/16) are refused with Dat adres kunnen we niet gebruiken. Gebruik een openbaar https-adres. - Redirects are never followed. A
301/302counts as a failure: give the final address. - Pinned address. The host name is resolved once, checked, and the connection goes to exactly that address. A DNS answer that changes between the check and the request (DNS rebinding) cannot redirect the delivery to an internal host.
In local development of the platform http and private addresses are allowed, so you can
point a webhook at a receiver on your machine.
Using it with automation tools
Any system that accepts an https POST with JSON works; none of these needs an app or a plugin.
In each case: create the trigger, copy its address into Adres (URL), click Test
versturen, then build the rest of the flow on the fields that arrived.
| Tool | Trigger |
|---|---|
| Zapier | Webhooks by Zapier → Catch Hook |
| Make | Webhooks → Custom webhook (click Redetermine data structure if fields do not appear) |
| n8n | a Webhook node |
| Power Automate | When an HTTP request is received |
Tips:
- The test
pinghas none of the real fields. To map fields, send a real event once (submit the website form, publish a page), then pick the fields from that sample. - Filter on
event(orx-webhook-event) when one URL listens to several events. - These tools do not check the signature by default. If the flow writes to something that
matters, verify
x-webhook-signaturein a code step with the secret, as above, or use your own receiver. - Answer quickly. Do the slow work after acknowledging, or the 10-second timeout turns into retries and, eventually, a paused webhook.
Where it lives
| Piece | Where |
|---|---|
Event keys, WEBHOOK_SIGNATURE_HEADER, whsec_ prefix | @kwisple/core (webhooks.ts) |
| Endpoints, signing, delivery, pausing, test | apps/dashboard/src/lib/services/webhooks.ts |
| Payloads | apps/dashboard/src/lib/services/webhook-events.ts |
| Outbound URL guard | @kwisple/core/net (safeFetch) |
| Settings page | /w/<client>/instellingen/webhooks |
The client-facing explanation is in the handleiding (Hulp) under "Webhooks naar je eigen systemen".