Webhooks
Webhooks
Kweko sends a signed HTTPS POST to your URL when something happens in a workspace: a lead moves, a client writes, an invoice is paid. No polling needed.
Set up a webhook
Owners and admins create webhooks in Settings → Developers → Webhooks; integrations can create them through the API with an API key that has the webhooks:manage scope:
curl https://api.kweko.uz/v1/webhooks \
-H "Authorization: Bearer $KWEKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Orders system", "url": "https://example.com/kweko/webhooks",
"events": ["lead.created", "lead.stage_changed", "invoice.*"]}'{
"webhook": { "id": "hook_01j8z9a1b2c3d4e5f6g7h8j9k0", "object": "webhook", "name": "Orders system", "url": "https://example.com/kweko/webhooks", "events": ["lead.created", "lead.stage_changed", "invoice.*"], "active": true, "status": "active" },
"secret": "whsec_x7Qm2…"
}The response shows the signing secret once. Store it with your receiver; you need it to verify signatures.
- Events. Up to 100 patterns: an event name (
lead.created), a whole group (lead.*) or everything (*). The event catalog lists them. - Filters. Optionally only deliver events of some pipelines, stages, owners or channels:
"filters": {"pipeline_ids": ["pipeline_…"], "stage_ids": [], "owner_ids": [], "channels": ["telegram"]}, up to 50 values each. A filter only applies to events that carry the matching field (pipeline_id,stage_id,owner_id,channel); other events pass. - URL. HTTPS on port 443 or 8443, a public address (no private, loopback or link-local IPs, no
localhost), no user name or password in the URL, at most 2048 characters. Kweko checks the address when you save it and again before every delivery. - How many. Depends on the plan: 2 on Free, 25 on Start, 100 on Pro (and during the trial), 250 on Business and Enterprise. Apps' event subscriptions don't count. Beyond that:
limit_reachedwithkey: "webhooks",limit,usedandupgrade_to. - Test.
POST /v1/webhooks/{id}/pingsends apingevent right away and answers with the result of the delivery.
The request
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Kweko-Webhooks/1.0 (+https://developers.kweko.uz/webhooks) |
Kweko-Event | The event type, for example lead.stage_changed |
Kweko-Event-Id | The event id, the same as id in the body. Stays the same on retries and replays. |
Kweko-Delivery | The delivery id (dlv_…). Stays the same on retries, new on a replay. |
Kweko-Webhook-Id | Your webhook's id (hook_…) |
Kweko-Timestamp | When this attempt was signed, in Unix seconds |
Kweko-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, see below |
Kweko-Replay | true, only on deliveries you replayed |
{
"id": "evt_482913",
"object": "event",
"type": "lead.stage_changed",
"api_version": "2026-09-01",
"occurred_at": "2026-09-27T09:41:12.482913Z",
"workspace_id": "ws_01j8z5w2k4h6j8m0n2p4r6t8v0",
"actor": { "type": "member", "id": "member_01j8z5x7y9z1a3b5c7d9e1f3g5" },
"data": {
"object": {
"id": "lead_01j8z7c2d4f6g8h0j2k4m6n8p0",
"object": "lead",
"from": "stage_01j8z5z0a2b4c6d8e0f2g4h6j8",
"to": "stage_01j8z5z0a2b4c6d8e0f2g4h6k1",
"pipeline_id": "pipeline_01j8z5y1b3c5d7e9f1g3h5j7k9",
"from_type": "open",
"to_type": "won"
}
}
}data.objectalways has theidandobject(type) of the record the event is about, plus the event's own fields, listed per event in the catalog. It is a summary of the change, not the full record: fetch the record through the API when you need all of it.actor.typeismember,api_key,automationorsystem;actor.idis the member (member_…), key (key_…) or automation (auto_…), and empty forsystem.- If an event is larger than 256 KB,
data.objectonly hasid,objectand"truncated": true. - Subscriptions to a group or to
*can also receive event types that are not in the catalog yet (for examplelead.reopened). Ignore types you do not handle.
Respond quickly
Answer with any 2xx status within 10 seconds. Anything else, a timeout or a connection error is a failed attempt. Do the real work after answering (put the event on a queue), so slow processing never turns into retries.
Kweko follows up to 3 redirects, only to the same host and scheme, reads at most 1 MB of your response and keeps the first 2 KB in the delivery log.
Verify signatures
Every request is signed with your webhook's secret, so you can check it came from Kweko and was not changed:
- Read
tandv1from theKweko-Signatureheader. - Compute HMAC-SHA256 with the whole secret (including
whsec_) as the key, overt, a dot and the raw request body, exactly as received. Hex-encode it in lowercase. - Compare it with
v1in constant time. - Reject the request if
tis more than 5 minutes away from your clock, so a captured request cannot be replayed later.
Verify before parsing: a JSON parser and serializer can change spaces or key order and break the signature.
import crypto from "node:crypto"
import express from "express"
function verifyKweko(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries((header || "").split(",").map((p) => p.trim().split("=", 2)))
const t = Number(parts.t)
if (!Number.isInteger(t) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex")
return expected.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}
const app = express()
app.post("/kweko/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyKweko(process.env.KWEKO_WEBHOOK_SECRET, req.get("Kweko-Signature"), req.body)) return res.sendStatus(400)
const event = JSON.parse(req.body)
queue.add(event) // handle it after answering
res.sendStatus(200)
})import hashlib, hmac, os, time
from flask import Flask, request, abort
def verify_kweko(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t, sig = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
app = Flask(__name__)
@app.post("/kweko/webhooks")
def kweko_webhook():
if not verify_kweko(os.environ["KWEKO_WEBHOOK_SECRET"], request.headers.get("Kweko-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
queue.put(event) # handle it after answering
return "", 200package kweko
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// VerifySignature checks a Kweko-Signature header against the raw request body.
func VerifySignature(secret, header string, body []byte, tolerance time.Duration) bool {
var ts, sig string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
switch k {
case "t":
ts = v
case "v1":
sig = v
}
}
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || sig == "" {
return false
}
if d := time.Since(time.Unix(n, 0)); d > tolerance || d < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
}<?php
function kweko_verify(string $secret, string $header, string $body, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
$parts[$k] = $v;
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
if (abs(time() - (int) $parts['t']) > $tolerance) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
return hash_equals($expected, $parts['v1']);
}
$body = file_get_contents('php://input');
if (!kweko_verify(getenv('KWEKO_WEBHOOK_SECRET'), $_SERVER['HTTP_KWEKO_SIGNATURE'] ?? '', $body)) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
http_response_code(200);In JavaScript apps you can also use verifySignature from @kweko/sdk/server, which implements the same check.
Check your code
With these values your function must return true (with the time check turned off or the tolerance large enough):
secret: whsec_4mVz2kQ9pXr7tLw3nB8yJc5hGd1sFa6eUo0iTk2qZx4
raw body: {"id":"evt_1","object":"event","type":"ping"}
Kweko-Signature: t=1727430000,v1=00b2fc22f0bf4bcd093bdf84ced94f2a45de1a859f0434a089508d7a446b3deeRetries
A failed attempt is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 6 hours, for as long as the next attempt falls within 24 hours of the event. After that the delivery is marked failed. Every attempt is signed again with a fresh timestamp.
Deliveries are at least once: in rare cases (a timeout after your server already processed the request, a Kweko worker restarting) the same event arrives twice. Use Kweko-Event-Id (or id in the body) to ignore duplicates.
Events are not ordered: deliveries run in parallel and retries are scheduled independently. Use occurred_at to order events about the same record, or fetch the record's current state from the API.
Disabling
Kweko disables a webhook, stops delivering to it and sends a webhook.disabled event to the workspace's other webhooks when:
- your endpoint answers 410 Gone (use it to unsubscribe from your side), or
- no delivery succeeded for 24 hours in a row.
There is no rule based on a number of failures in a row: a busy workspace can pile up hundreds of failed attempts during a ten-minute outage of your receiver, and time is the fair measure. 24 hours is also how long each delivery is retried.
The workspace's owners and admins are told in Kweko and by email once an hour into a failure streak (the webhook keeps working and retrying) and again when it is disabled.
Deliveries still queued for it are marked failed. Fix the receiver, then turn the webhook back on:
POST /v1/webhooks/{id}/enablewith{"resend_failed": true}turns it on and queues again every delivery that failed in the last 3 days (oldest first, at most 1,000; test pings and deliveries already delivered by a replay are skipped). It answers{"webhook": {…}, "resent": 12}. Settings shows the same action as Turn on and resend failed.PATCH /v1/webhooks/{id}with{"active": true}only turns it on.
Both reset the failure count. A successful delivery at any time resets it too.
Delivery log and replays
GET /v1/webhooks/{id}/deliveries lists deliveries with their status (pending, retrying, success, failed), attempts, response status and latency; GET /v1/webhooks/{id}/deliveries/{did} adds the payload and the start of your response. POST /v1/webhooks/{id}/deliveries/{did}/replay sends a delivery again, with the same event id and Kweko-Replay: true. Settings → Developers → Webhooks shows the same log.
Secrets
Secrets look like whsec_ followed by 43 characters. POST /v1/webhooks/{id}/rotate-secret replaces the secret immediately (there is no overlap period) and returns the new one once; deliveries still waiting for a retry are signed with the new secret. To rotate without failed verifications, accept both the old and the new secret in your receiver for a few minutes.
Security checklist
- Verify every signature and reject old timestamps.
- Use a URL that is hard to guess and serve it over HTTPS only (Kweko requires it).
- Treat
data.objectas a hint and read important state (an invoice's status, a lead's amount) from the API before acting on it. - Answer fast and process asynchronously.