Webhooks
Kodkila sends a signed POST to your HTTPS endpoint when something changes on your channel.
Add an endpoint
In the console, open your channel, then Developers › Webhooks. Give an https:// URL on a public host and choose the events. The signing secret, starting whsec_, is shown once. A channel can have up to 20 endpoints. You can send a test ping, read the delivery log and redeliver any event.
Events
| Type | Sent when |
|---|---|
order.created | An order is placed (seller view: totals, lines, shipping, status) |
order.status_changed | An order moves to another status, by the buyer or the seller |
product.created, product.updated | A product is added or changed |
product.deleted | A product is removed (a product with orders is archived instead) |
cms.surface.published, cms.surface.unpublished | A home surface goes live, changes or is taken off |
webhook.ping | You send a test ping |
Delivery
POST /hooks/kodkila HTTP/1.1
Content-Type: application/json
Kodkila-Signature: t=1759300000,v1=5257a869e7ec…
Kodkila-Event-Id: evt_9f1c…
Kodkila-Event-Type: order.created
Kodkila-Delivery-Id: 6b0e…
Kodkila-Delivery-Attempt: 1
{"id":"evt_9f1c…","type":"order.created","created":"2026-10-02T10:00:00.000Z","channel_id":"…","data":{…}}- Answer with any 2xx within 10 seconds. Anything else counts as a failure, and redirects are not followed.
- Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.
- After 15 failures in a row the endpoint is turned off. Fix it, then turn it back on in the console.
- Delivery is at least once and not strictly in order, so skip events whose
idyou have already handled. - The same headers are also sent with a
Westmonk-prefix, the old name. ReadKodkila-first.
Check the signature
v1 is the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with your endpoint’s secret. Compute it over the body exactly as received, before any JSON parsing. Accept the event if any v1 matches in constant time and t is within 300 seconds of now. During a secret rotation the header carries two v1 values.
const crypto = require("crypto");
const express = require("express");
const SECRET = process.env.KODKILA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 300;
function verify(header, rawBody, secret) {
let t = null;
const sigs = [];
for (const part of String(header || "").split(",")) {
const [k, v] = part.split("=", 2);
if (k === "t") t = Number(v);
if (k === "v1") sigs.push(v);
}
if (!Number.isInteger(t) || sigs.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_S) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}
const app = express();
app.post("/hooks/kodkila", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(req.get("Kodkila-Signature"), raw, SECRET)) return res.sendStatus(400);
const event = JSON.parse(raw);
// skip event.id if you have seen it, then handle event.type
res.sendStatus(200);
});
app.listen(3000);import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["KODKILA_WEBHOOK_SECRET"].encode() # whsec_…
TOLERANCE_S = 300
def verify(header: str, raw_body: bytes, secret: bytes) -> bool:
t, sigs = None, []
for part in (header or "").split(","):
k, _, v = part.partition("=")
if k == "t" and v.isdigit():
t = int(v)
elif k == "v1":
sigs.append(v)
if t is None or not sigs or abs(int(time.time()) - t) > TOLERANCE_S:
return False
expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
app = Flask(__name__)
@app.post("/hooks/kodkila")
def kodkila_hook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(request.headers.get("Kodkila-Signature", ""), raw, SECRET):
abort(400)
event = request.get_json()
# skip event["id"] if you have seen it, then handle event["type"]
return "", 200