A webhook sends an event to your URL the moment something happens in Kwazi: a new contact, a form submission, a booking. Create one under Kwazi → Integrations → Webhooks (or POST /webhooks), choose the events, and copy the signing secret — it is shown once.
Events
| Events | Sent when |
|---|---|
contact.created, contact.updated, contact.deleted, contact.restored, contact.merged, contact.erased |
A contact is added, changed (data.changed names the fields), moved to the trash, restored, merged (data.merged_id is the record that went), or erased (only data.contact.id remains) |
organization.created, organization.updated, organization.deleted, organization.restored, organization.merged |
The same for organizations |
marketing.subscribed, marketing.unsubscribed |
A contact confirmed they want marketing emails (after the confirmation email), or unsubscribed (link in a mail, or by the team). data.contact holds the contact with its new marketing status. Use these to keep an email tool in step. |
form.submitted |
A form is submitted (spam is not sent) |
appointment.booked, appointment.confirmed, appointment.declined, appointment.rescheduled, appointment.place_changed, appointment.cancelled, appointment.completed, appointment.no_show |
An appointment is booked or its status, time or place changes |
Only events of apps that are switched on can be chosen (GET /webhooks/events).
The request
Kwazi sends a POST with a JSON body:
{
"event": "appointment.booked",
"id": "evt_01a1402a-e4ef-77a0-ac5d-280d9ef5d684",
"occurred_at": "2026-10-15T09:02:11Z",
"data": {
"appointment": { "id": "…", "status": "confirmed", "service": { "id": "…", "name": "Intro call" }, "starts_at": "…", "…": "…" }
}
}
data holds the record in the same shape as the REST API. It never holds tokens, meeting links, internal notes or internal reasons.
Headers:
| Header | Value |
|---|---|
X-Kwazi-Event |
The event, e.g. appointment.booked |
X-Kwazi-Delivery |
The id of this delivery |
X-Kwazi-Signature |
t=<unix time of sending>,v1=<hex HMAC-SHA256 of "<t>.<body>" with your secret> |
Verify the signature
Always check the signature before you trust a request, on the raw body (before parsing JSON), with a constant-time comparison, and refuse requests older than 5 minutes (replays).
PHP (verify-signature.php)
function kwazi_verify_signature( string $secret, string $header, string $body, int $now, int $tolerance = 300 ): bool {
if ( 1 !== preg_match( '/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $parts ) ) {
return false;
}
if ( abs( $now - (int) $parts[1] ) > $tolerance ) {
return false;
}
return hash_equals( hash_hmac( 'sha256', $parts[1] . '.' . $body, $secret ), $parts[2] );
}
$ok = kwazi_verify_signature( $secret, $_SERVER['HTTP_X_KWAZI_SIGNATURE'] ?? '', file_get_contents( 'php://input' ), time() );
Node.js (verify-signature.mjs)
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyKwaziSignature( secret, header, body, now, tolerance = 300 ) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec( header || '' );
if ( ! match || Math.abs( now - Number( match[ 1 ] ) ) > tolerance ) {
return false;
}
const expected = createHmac( 'sha256', secret ).update( `${ match[ 1 ] }.${ body }` ).digest();
return timingSafeEqual( expected, Buffer.from( match[ 2 ], 'hex' ) );
}
Python (verify_signature.py)
import hashlib, hmac, re
def verify_kwazi_signature(secret, header, body, now, tolerance=300):
match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not match or abs(now - int(match.group(1))) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{match.group(1)}.{body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, match.group(2))
All three examples are tested against signatures made by Kwazi itself. Test vector: secret whsec_test, body {"event":"contact.created"}, time 1791882131 → t=1791882131,v1=444afe55dcbccf746bb018dec31d03a64c1a9a385d803d1cad3b2369171d1289.
Answer, retries and pausing
- Answer with any 2xx status within 5 seconds. Do slow work afterwards.
- Anything else (another status, a timeout, no connection) is retried: after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours (6 attempts in all). Retries are sent by WordPress’s scheduler, so on a quiet site they can come a few minutes later.
- Every attempt carries the same
id: use it to ignore an event you already handled. - After 20 failed attempts in a row Kwazi pauses the webhook and emails the site’s administrator. Switch it on again after fixing the receiving side. If Kwazi can no longer read the secret (the site’s security keys changed), the webhook pauses too; make a new secret.
- Redirects are not followed. Kwazi only sends to
httpson port 443 of a public address. - Deliveries (per webhook) shows every attempt of the last 30 days with the body and the answer, and can resend one. Send test event sends a
webhook.testevent.
