Before you start
You need to be an owner or admin of your Codeskop organization. Webhook alerts are included from the Starter plan, and a webhook channel can be used by any project's alert rules.Connect a webhook
Add the endpoint
In Settings → Notifications, choose Webhook → Connect, give it a name and paste your endpoint's URL.
Copy the signing secret
Codeskop shows the secret (it starts with
whsec_) once. Store it where your endpoint can read it, for example asCODESKOP_WEBHOOK_SECRET. Afterwards the dashboard only shows its first characters.Send a test
Connecting doesn't send anything, because your endpoint can't verify a request until it has the secret. Once it has, press Send test: Codeskop sends one
alert.testrequest and expects a2xxresponse.Add it to alert rules
Pick the webhook under Send to in any alert rule, alongside your other channels.
Which URLs are accepted
https://only, with no username or password in the URL.- Port 443, or a port from 1024 to 65535.
- A public address. URLs that point to private, loopback, link-local or cloud metadata addresses (for example
localhost,10.0.0.5or169.254.169.254) are refused, when you connect and again before every delivery. - Redirects aren't followed. Use the final URL.
The request
Each alert is one POST with a compact JSON body and these headers:
| Option | Type | Description |
|---|---|---|
| Content-Type | header | application/json |
| User-Agent | header | Codeskop-Webhooks/1.0 |
| Codeskop-Event | header | alert.fired for a real alert, alert.test for Send test. |
| Codeskop-Delivery | header | The delivery's ID. It stays the same when a delivery is retried, so use it to ignore duplicates. |
| Codeskop-Signature | header | The timestamp and signature: see Verify the signature. |
{ "type": "alert.fired", "delivery_id": "6f1e2d3c-4b5a-4968-8776-5a4b3c2d1e0f", "attempt": 1, "sent_at": "2026-10-27T10:40:01.904115+00:00", "test": false, "alert": { "id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e", "title": "New crash: NullPointerException in CheckoutViewModel", "summary": "CheckoutViewModel.onPay (CheckoutViewModel.kt:142)", "severity": "high", "kind": "issue", "subject_id": "9f3a1c2b7d4e5f60", "fired_at": "2026-10-27T10:40:01.512908+00:00", "url": "https://dashboard.codeskop.com/issues/9f3a1c2b7d4e5f60?project=4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f&env=production", "facts": [ {"label": "Events", "value": "12"}, {"label": "Users affected", "value": "7"}, {"label": "Top device", "value": "Google Pixel 7 (58%)"}, {"label": "App version", "value": "4.12.0 (100%)"} ] }, "rule": {"id": "8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d", "name": "New crashes in checkout", "type": "new_issue"}, "project": {"id": "4c5d6e7f-8a9b-4c0d-9e1f-2a3b4c5d6e7f", "name": "Acme Android"}, "environment": {"id": "0d1e2f3a-4b5c-4d6e-8f7a-9b0c1d2e3f4a", "name": "production"}, "organization": {"id": "9d2e4f6a-8b0c-4d1e-a2f3-b4c5d6e7f809", "name": "Acme"}}alert.kindisissueorendpoint(a slow or failing API). For an endpoint,subject_idis the endpoint's ID.alert.factsare the same facts the chat channels show, as label and value strings.environment.nameis lowercase, for exampleproductionortest.ruleisnullif the rule has since been deleted.alert.testhas the same shape with"test": true. Itsalert.id,alert.subject_id,project.id,environment.idandrulearenull, andproject.nameis your organization's name.
Payloads never include stack traces, user IDs, emails or event payloads: only what the chat channels already show, plus a link to the issue.
Responses and retries
| Option | Type | Description |
|---|---|---|
| 2xx | delivered | The alert is marked sent. |
| 408, 425, 429, 5xx | retried | Also timeouts and connection errors. Codeskop tries 5 times in all, waiting 30 seconds, 1, 2 and 4 minutes between attempts. |
| 3xx | failed | Redirects aren't followed. Update the channel with the final URL. |
| Other 4xx | failed | For example 401, 403, 404 or 410. Not retried: fix the endpoint, then press Send test. |
Codeskop waits up to 5 seconds to connect and 10 seconds for the response (15 seconds in all), and reads at most 64 KB of what you send back. Reply quickly and do slow work after responding. Every attempt is signed again with a fresh timestamp and carries its number in attempt. A failed delivery shows in Recent alerts, and the channel is flagged Last delivery failed.
Verify the signature
Every request carries a Codeskop-Signature header: an HMAC-SHA256 of the timestamp and the raw body, keyed with your signing secret. Check it before you trust the request. Segment webhooks use the same scheme, so one function can verify both.
Codeskop-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>signed string = "<t>." + <raw request body bytes>key = the whole secret as UTF-8, including the "whsec_" prefix- Split the header on commas. Take
tand everyv1value. - Reject the request if
tis more than 5 minutes from your clock. This stops replays. - Compute the HMAC-SHA256 of
t, a full stop, and the raw request body, with the whole secret (includingwhsec_) as the key, as lowercase hex. - Accept the request if it equals any
v1value. Use a constant-time comparison.
Use the raw body
Sign the bytes exactly as they arrived. Parsing the JSON and serialising it again changes the spacing and key order, and the signature won't match.import hashlib, hmac, os, timefrom flask import Flask, abort, request app = Flask(__name__)SECRETS = [os.environ["CODESKOP_WEBHOOK_SECRET"]] # add the old secret here while you roll a rotationTOLERANCE = 300 def verify(raw: bytes, header: str, secrets: list[str], now: float | None = None) -> bool: parts = [p.split("=", 1) for p in header.split(",") if "=" in p] ts = next((v for k, v in parts if k == "t"), None) sigs = [v for k, v in parts if k == "v1"] if not ts or not ts.isdigit() or not sigs: return False if abs((now or time.time()) - int(ts)) > TOLERANCE: return False for secret in secrets: expected = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest() if any(hmac.compare_digest(expected, s) for s in sigs): return True return False @app.post("/codeskop")def codeskop(): if not verify(request.get_data(), request.headers.get("Codeskop-Signature", ""), SECRETS): abort(400) event = request.get_json() # Deduplicate on request.headers["Codeskop-Delivery"]: retries reuse it. return "", 204const crypto = require("crypto");const express = require("express"); const SECRETS = [process.env.CODESKOP_WEBHOOK_SECRET]; // add the old secret here while you roll a rotationconst TOLERANCE = 300; function verify(raw, header, secrets, now = Date.now() / 1000) { const parts = (header || "").split(",").map((p) => p.split("=", 2)); const ts = parts.find(([k]) => k === "t")?.[1]; const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); if (!ts || !/^\d+$/.test(ts) || sigs.length === 0) return false; if (Math.abs(now - Number(ts)) > TOLERANCE) return false; return secrets.some((secret) => { const expected = crypto.createHmac("sha256", secret).update(`${ts}.`).update(raw).digest("hex"); const a = Buffer.from(expected); return sigs.some((s) => { const b = Buffer.from(s); return a.length === b.length && crypto.timingSafeEqual(a, b); }); });} const app = express();app.post("/codeskop", express.raw({ type: "application/json" }), (req, res) => { if (!verify(req.body, req.get("Codeskop-Signature"), SECRETS)) return res.sendStatus(400); const event = JSON.parse(req.body.toString("utf8")); // Deduplicate on req.get("Codeskop-Delivery"): retries reuse it. res.sendStatus(204);});app.listen(3000);Test vector
Check your implementation with these values (pin your clock to t, or the timestamp check fails):
secret whsec_3qT8vZ1kXo9bN2mLr7YcPw4sJd6HfA0et 1793092445 (2026-10-27T09:14:05Z)body {"type":"alert.test","delivery_id":"0b9e4c2a-6d1f-4a8e-9c3b-5f7a2d1e8c40","attempt":1,"sent_at":"2026-10-27T09:14:05.221904+00:00","test":true}header t=1793092445,v1=0c9d5a15669eeb054d2133836c24785b85a0ed4e7ad22e245c2fbfe55968db80Rotate the secret
If a secret leaks, or as routine, press Rotate secret on the channel. The new secret is shown once. For the next 24 hours, Codeskop signs with both, current first:
Codeskop-Signature: t=1793092445,v1=<signature with the new secret>,v1=<signature with the previous secret>Because receivers accept a request when any v1 matches, you can deploy the new secret within that window without dropping alerts. After 24 hours only the new secret is used. Rotating again during the window replaces the previous secret. Changing the endpoint URL doesn't rotate the secret.
Security
- The URL and the secret are stored encrypted. After you connect, the dashboard shows only the host and the first characters of the secret.
- Query strings in your URL are kept private, so a token in the URL stays secret, but a signature check is still the safer way to authenticate requests.
- If an alert arrives without a valid signature, reject it with a
400; it didn't come from Codeskop.