Webhooks
Long jobs (imports, geocoding) and paid runs bound to a saved analysis can notify your system instead of being polled. Register an HTTPS endpoint, choose the events, and K-Stra POSTs each event to it, signed with a secret only you and K-Stra know.
Webhooks belong to your organization and are managed with a key that has the manage scope.
Register an endpoint
curl -X POST https://www.k-stra.com/api/v1/webhooks \
-H "Authorization: Bearer ks_live_…" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/kstra/webhook","events":["job.completed","job.failed","analysis.version.created"],"description":"Production"}'
{
"webhook": { "id": 12, "url": "https://example.com/kstra/webhook", "events": ["job.completed","job.failed","analysis.version.created"], "status": "active", … },
"secret": "whsec_…"
}
The answer is 201 Created. The secret is shown once: store it where you verify signatures. Rules for the URL: absolute, https, reachable from the internet (no private addresses), no credentials in it. Up to 10 active endpoints per organization.
Other routes, all with scope manage:
| Route | What it does |
|---|---|
GET /api/v1/webhooks |
your endpoints and the list of available events |
DELETE /api/v1/webhooks/{id} |
delete an endpoint |
POST /api/v1/webhooks/{id}/enable |
re-enable an endpoint disabled after repeated failures |
GET /api/v1/webhooks/{id}/deliveries?limit=50 |
the last deliveries (50 by default, up to 200): event, status, attempts, next attempt, response code, error and the payload sent |
POST /api/v1/webhooks/{id}/ping |
sends a ping event now and returns the delivery outcome (the endpoint must listen to ping) |
Events
| Event | When | data |
|---|---|---|
job.completed |
a background job of your organization finished (import, geocoding, snap to nodes, an enrich.points batch), or was stopped by its owner (job.status is succeeded or stopped) |
{ "job": … } — the same object as GET /api/v1/jobs/{id}; for a batch, job.result is the summary (done, skipped, credits charged) |
job.failed |
a job failed | { "job": … } with job.error |
analysis.version.created |
a charged run bound to an analysis of your organization was stored as a version, whether it came from the web app, the API or MCP | { "analysisCode": "…", "version": { "versionNo", "actionId", "creditsCharged", "dataVersion", "createdAt" } } — read it with GET /api/v1/analyses/{code}/versions/{no} |
ping |
you asked for a test | { "message": "K-Stra webhook test" } |
Every delivery is one JSON object:
{
"id": "evt_3fJ9…",
"type": "analysis.version.created",
"createdAt": "2026-09-23T14:02:11.418Z",
"data": { "analysisCode": "TkVkhEZuoig7", "version": { "versionNo": 3, "actionId": "analysis.people_economy", "creditsCharged": 2, "dataVersion": { "territory": "2026-08", "sociodemo": "2024" }, "createdAt": "2026-09-23T14:02:11.401Z" } }
}
id is the same for every endpoint that receives the event: use it to deduplicate, because a retry after a timeout may deliver the same event twice.
Headers and signature
| Header | Value |
|---|---|
Content-Type |
application/json |
X-KStra-Event |
the event type |
X-KStra-Delivery |
the event id |
X-KStra-Attempt |
1, 2, 3 or 4 |
X-KStra-Signature |
t=<unix seconds>,v1=<hex> where hex = HMAC-SHA256(secret, "<t>.<raw body>") |
User-Agent |
K-Stra-Webhooks/1.0 (+https://www.k-stra.com/developers/webhooks) |
Verify before trusting the body: recompute the HMAC over the raw request body (not a re-serialized one), compare in constant time, and reject timestamps older than five minutes.
// Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
}
# Python
import hmac, hashlib, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
Answer with any 2xx within 10 seconds; do the real work afterwards. Anything else, or no answer, counts as a failure; redirects are not followed, so a 3xx is a failure too.
Retries and disabling
A failed delivery is retried three more times: after 1 minute, 10 minutes and 1 hour. Then it is marked failed and stays in the deliveries list for 30 days. An endpoint with 20 failed attempts in a row (retries count) is disabled (status: "disabled") and receives nothing until you call /enable; re-enabling resets the failure count.
Deliveries are not guaranteed to arrive in order.