Reference
Webhooks
Keepcord POSTs a signed JSON payload to your endpoint for every event you subscribe to. Verify the signature, respond 2xx quickly, and do the work asynchronously.
- 1
Dashboard → Developer → Webhooks → Add
HTTPS URL, one or more events (wildcards allowed: * or pull.*). Private and loopback addresses are refused. - 2
Copy the secret
Shown once. Used to sign every delivery. - 3
Send a test
The Test button delivers asettings.updatedevent immediately so you can check your handler.
Webhooks are a Premium and Plus feature.
| Event | Meaning | Notes |
|---|---|---|
| verify.success | A member verified and was allowed. | targetId = user id; data: status, trustScore, country, flags |
| verify.flagged | Verified but flagged (alt suspect, rule). | data: flags, reason |
| verify.blocked | Verification refused. | data: reason (vpn, blacklist, country, age, rule), vpnProvider |
| verify.revoked | Member revoked the app or the token could not be refreshed. | |
| member.deleted | Verification data deleted (dashboard, /deluser, API, retention). | |
| member.blacklisted | User added to the blacklist. | data: reason, kinds |
| member.whitelisted | User added to the whitelist. | |
| pull.started / pull.progress / pull.completed / pull.failed | Pull job lifecycle. progress is sent at most every 30 seconds. | data: jobId, progress |
| backup.started / backup.completed / backup.failed | Backup job lifecycle. | data: jobId, version, stats |
| restore.started / restore.completed / restore.failed | Restore job lifecycle. | data: jobId, mode, plan |
| incident.nuke | Anti-nuke triggered. | data: actorId, actions, action taken, backupVersion |
| incident.mass_ban | Many bans in a short window. | data: actorId, count |
| incident.guild_deleted | Keepcord lost the server (deleted or bot kicked and server gone). | |
| incident.bot_removed | The bot was removed from the server. | |
| guild.joined / guild.left | Bot joined or left. | |
| settings.updated | Dashboard settings changed. | data: keys |
| team.updated | Team member added, changed or removed. | |
| job.cancelled | A job was cancelled. | data: jobId, type |
Delivery
POST /hooks/keepcord HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Keepcord-Webhooks/1.0
X-Keepcord-Event: verify.blocked
X-Keepcord-Timestamp: 1760950872
X-Keepcord-Signature: sha256=5b1c0f…e2a9
{"event":"verify.blocked","guildId":"987654321098765432","timestamp":"2026-10-20T09:41:12.000Z","actorId":null,"targetId":"240000000000000077","message":"Blocked: VPN detected (NordVPN)","data":{"reason":"vpn","vpnProvider":"NordVPN","country":"NL"}}| Header | Value |
|---|---|
| X-Keepcord-Event | Event type, same as the body's event field. |
| X-Keepcord-Timestamp | Unix seconds when the delivery was signed. Reject deliveries older than 5 minutes to prevent replays. |
| X-Keepcord-Signature | sha256= + hex HMAC-SHA256 of {timestamp}.{rawBody} with your secret. |
Use the raw body
Compute the HMAC over the exact bytes you received, before any JSON parsing or re-serialization. Frameworks that parse JSON automatically usually expose the raw body through a dedicated option.import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.KEEPCORD_WEBHOOK_SECRET;
app.post("/hooks/keepcord", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("x-keepcord-timestamp") ?? "";
const given = req.header("x-keepcord-signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const expected = "sha256=" + createHmac("sha256", SECRET).update(`${ts}.${req.body}`).digest("hex");
if (given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
res.sendStatus(204); // ack first
queueMicrotask(() => handle(event)); // then work
});
function handle(event) {
if (event.event === "verify.blocked") console.log("blocked", event.targetId, event.data.reason);
}
app.listen(3000);- A delivery is successful on any
2xxwithin 8 seconds. - Failures are retried with exponential back-off after 1, 2, 4 and 8 minutes (5 attempts in total), then the delivery is marked failed.
- Each delivery, its response code and attempt count is visible under Developer → Webhooks → Deliveries.
- A successful delivery resets the failure counter; after 25 consecutive failed attempts the webhook is disabled and shown as such in the dashboard. Re-enable it once your endpoint is back.
- Deliveries are at-least-once. Use
X-Keepcord-TimestampplustargetId/data.jobIdas an idempotency key if duplicates matter.
Expose a local port with a tunnel (ngrok, cloudflared, tailscale funnel) and point the webhook at the public URL. The Test button in the dashboard sends a real signed delivery. You can also replay the signature logic offline:
import { createHmac } from "node:crypto";
const secret = "whsec_test";
const ts = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "settings.updated", guildId: "1", timestamp: new Date().toISOString(), actorId: null, targetId: null, message: "test", data: {} });
console.log("X-Keepcord-Timestamp:", ts);
console.log("X-Keepcord-Signature: sha256=" + createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex"));Prefer polling? The same events are available through GET /v1/logs.