Kwisple
Browse the docs

Integrations

Outgoing webhooks

Signed HTTP notifications per client for form submissions, leads, requests and publications, with the payloads, signature check, retries and delivery log.

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.

  1. Click Webhook toevoegen (add webhook).
  2. Fill in Adres (URL): the https address of the receiving system.
  3. Optionally give it a description (placeholder Bv. Leads naar Teamleader).
  4. Tick the Gebeurtenissen (events) you want, at least one.
  5. 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 keyLabel in the dashboardSent when
form.submittedEen formulier werd verstuurdA 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.updatedEen aanvraag kreeg een nieuwe status of waardeA submission's follow-up status (Opvolging), result or value changed, in Formulieren or through an app (apps API).
request.createdEen nieuw verzoekA request between client and agency was created, by either side.
request.repliedEen antwoord op een verzoekSomeone replied to a request, either side.
post.publishedEen post staat onlineA social post is online on every platform it was meant for, published automatically or marked by hand. Once per post.
page.publishedEen pagina werd gepubliceerdA 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":{…}}
HeaderValue
x-webhook-idThe event id (evt_ + 24 hex characters). The same on every retry: use it to drop duplicates.
x-webhook-eventThe event key, so you can route before parsing the body.
x-webhook-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>, see Verifying the signature.

The envelope

Every body has the same outer shape:

FieldTypeMeaning
idstringEvent id, equal to x-webhook-id.
eventstringEvent key.
occurredAtstringISO time the event happened (not the time of this attempt).
workspace{ id, name }The client.
dataobjectThe 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 2xx answer. 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 same x-webhook-id twice (a retry after a slow 2xx), so deduplicate on it.
  • No ordering guarantee. Deliveries run in parallel (five at a time per worker) and retries arrive later. Use occurredAt when 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:

ShownCodeMeaning
geen antwoord binnen 10 secondentimeoutNo response within 10 s.
geen verbindingconnectionDNS, TLS or connection failure.
adres niet toegelatenunsafeThe address failed the outbound check (below).
gepauzeerdpausedTest on a paused webhook.
verwijderdremovedThe 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 https addresses only. http and private, loopback or link-local addresses (such as localhost, 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/302 counts 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.

ToolTrigger
ZapierWebhooks by Zapier → Catch Hook
MakeWebhooks → Custom webhook (click Redetermine data structure if fields do not appear)
n8na Webhook node
Power AutomateWhen an HTTP request is received

Tips:

  • The test ping has 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 (or x-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-signature in 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

PieceWhere
Event keys, WEBHOOK_SIGNATURE_HEADER, whsec_ prefix@kwisple/core (webhooks.ts)
Endpoints, signing, delivery, pausing, testapps/dashboard/src/lib/services/webhooks.ts
Payloadsapps/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".

Last updated 2026-10-30