Webhooks

Webhooks

Get an event at your URL the moment something happens in Kwazi, signed and retried.

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 https on 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.test event.

Still stuck?

A real person answers, in plain words. Use the help form, or ask on the WordPress forum.

Ask us a question