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:

Request
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.*"]}'
Response
{
  "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_reached with key: "webhooks", limit, used and upgrade_to.
  • Test. POST /v1/webhooks/{id}/ping sends a ping event right away and answers with the result of the delivery.

The request

Each delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentKweko-Webhooks/1.0 (+https://developers.kweko.uz/webhooks)
Kweko-EventThe event type, for example lead.stage_changed
Kweko-Event-IdThe event id, the same as id in the body. Stays the same on retries and replays.
Kweko-DeliveryThe delivery id (dlv_…). Stays the same on retries, new on a replay.
Kweko-Webhook-IdYour webhook's id (hook_…)
Kweko-TimestampWhen this attempt was signed, in Unix seconds
Kweko-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>, see below
Kweko-Replaytrue, only on deliveries you replayed
Body
{
  "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.object always has the id and object (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.type is member, api_key, automation or system; actor.id is the member (member_…), key (key_…) or automation (auto_…), and empty for system.
  • If an event is larger than 256 KB, data.object only has id, object and "truncated": true.
  • Subscriptions to a group or to * can also receive event types that are not in the catalog yet (for example lead.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:

  1. Read t and v1 from the Kweko-Signature header.
  2. Compute HMAC-SHA256 with the whole secret (including whsec_) as the key, over t, a dot and the raw request body, exactly as received. Hex-encode it in lowercase.
  3. Compare it with v1 in constant time.
  4. Reject the request if t is 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.

Node.js (Express)
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)
})
Python (Flask)
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 "", 200
Go
package 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
<?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):

Test vector
secret:           whsec_4mVz2kQ9pXr7tLw3nB8yJc5hGd1sFa6eUo0iTk2qZx4
raw body:         {"id":"evt_1","object":"event","type":"ping"}
Kweko-Signature:  t=1727430000,v1=00b2fc22f0bf4bcd093bdf84ced94f2a45de1a859f0434a089508d7a446b3dee

Retries

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}/enable with {"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.object as 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.