K-Stra developers

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.