Inbox items, sent to your own endpoint.
When an agent sends to your Inbox, a question, a blocker or a finished task, Kinjot can POST it to an HTTPS address of yours: a chat bot, a phone notification service, or a small function of your own. This page is for whoever writes that receiver. Webhooks are part of the hosted service at kinjot.com; a self-hosted deployment has none.
Set it up
In the app, open Settings → Inbox → Webhook, and give it an address. Kinjot sends only to https:// on the standard port, 443, and only to a public domain name: an IP address, localhost, a .local or .internal name, or a name that resolves to a private address is refused, when you save and again before every delivery. Choose which kinds to send, the payload (Full or Title only), and when:
- Immediately: each new item on its own. Past 30 in an hour, further items fold into one batch sent five minutes later.
- Hourly digest: one list of the hour’s items, at the top of the hour.
- Daily digest: one list a day, at an hour you choose in your time zone.
Quiet hours, for immediate delivery, hold the items that arrive inside the window and send them as one batch when it ends. A repeat of an item that is still open is never sent again.
Creating the webhook shows its signing secret once, starting whsec_. Store it with your receiver: Kinjot never shows it again, only its last four characters. Send test sends one inbox.test delivery at once and shows what your endpoint answered.
What each request carries
Every delivery is one POST with a JSON body, signed as the Standard Webhooks specification describes:
webhook-id: the delivery’s id. It stays the same across retries of one delivery, so dedupe on it: delivery is at least once.webhook-timestamp: the attempt’s time, in Unix seconds. Each retry has a fresh one.webhook-signature:v1,and a base64 HMAC-SHA256, or two of them separated by a space (below).content-type: application/jsonanduser-agent: Kinjot-Webhooks/1.
Answer with any 2xx status within 10 seconds. Kinjot never reads the response body, and never follows a redirect: a 3xx counts as a failed attempt, so give Kinjot the final address.
Verify the signature
The signature is an HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed by the base64-decoded part of your secret after whsec_, then base64 encoded. Check it against the raw body, byte for byte, before you parse the JSON, and refuse a timestamp more than five minutes from your clock, which stops a replayed request.
The Standard Webhooks libraries do all of this. In JavaScript, with the secret in your receiver’s environment:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KINJOT_WEBHOOK_SECRET);
// Throws unless the signature and the timestamp check out.
const event = wh.verify(rawBody, headers);Or by hand, in Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyKinjotWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const sent = Number(timestamp);
if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
// For a day after a rotation there are two signatures: either may match.
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const given = Buffer.from(value, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Rotating the secret. Rotate secret, in the same settings, shows a new secret once. For the next 24 hours every delivery carries two signatures, one made with the new secret and one with the old, so a receiver that still holds the old secret keeps accepting deliveries while you switch it over. After 24 hours only the new secret signs.
The bodies
Each body has a type, a timestamp (when the delivery was created) and data. An inbox.item is one new item. With the Full payload:
{
"type": "inbox.item",
"timestamp": "2026-09-25T10:00:00.000Z",
"data": {
"id": "<item id>",
"kind": "blocker",
"title": "CI deploy-gate blocked by the billing limit",
"detail": "The deploy-gate job failed in 2 seconds with no steps.",
"context": {
"agent": "codex",
"repo": "acme/api",
"branch": "main",
"prs": ["https://github.com/acme/api/pull/42"]
},
"key": { "name": "work laptop" },
"created_at": "2026-09-25T09:59:58.120Z",
"surfaced_at": "2026-09-25T09:59:58.120Z",
"url": "https://kinjot.com/app?inbox=<item id>"
}
}kind is one of question, blocker, handoff, done and waiting. detail may be null; context holds only the fields the agent sent, from agent, repo, branch, prs, note (a note label such as A42) and session_id; and key.name is the name you gave the API key that sent it, or null. id is the item’s UUID, and url opens the item in Kinjot.
With Title only, data keeps id, kind, title, created_at, surfaced_at and url, and nothing else: no detail, context or key name leaves Kinjot.
An inbox.batch (quiet hours ending, or the burst fold) and an inbox.digest (hourly or daily) carry a list, in either payload mode, and never an item’s detail:
{
"type": "inbox.digest",
"timestamp": "2026-09-25T08:12:40.180Z",
"data": {
"items": [
{
"id": "<item id>",
"kind": "question",
"title": "Use the v8 or v4 UUID for waiting ids?",
"repo": "acme/api",
"created_at": "2026-09-25T08:12:40.000Z",
"url": "https://kinjot.com/app?inbox=<item id>"
}
],
"overflow": 0,
"window": { "from": "2026-09-25T08:12:40.180Z", "to": "2026-09-25T09:00:02.500Z" }
}
}A list holds at most 50 items and 20,000 bytes of body. overflow counts the items left out; they are in your Inbox. repo is null when the agent sent none. window runs from when the list’s first item opened it to when the body was built, at its first attempt. A retry sends the same bytes, so its window does not move; a resend from Settings builds the body again. An inbox.test has an empty data object.
Every string is untrusted plain text
Titles, details and context values are written by agents, and an agent can be misled by what it reads: a web page, an issue, a file in a repository. Kinjot removes the secrets it recognises and control characters, and nothing more. Treat every string in the body as untrusted plain text:
- Render it as text, never as HTML or Markdown. Escape it for wherever it lands: HTML entities for a web page, the markup characters of your chat service for a chat message.
- Don’t let your receiver follow links or run anything based on it, and don’t hand it to another agent or model as instructions. Chat and email clients turn URLs in text into links, so a title can carry a link nobody chose to send you.
- Use
urlto reach the item: Kinjot builds it from the item’s id, not from agent text.
Retries, and what switches a webhook off
A delivery gets at most five attempts: at once, then 5 minutes, 30 minutes, 2 hours and 8 hours after each failed one, rounded up to Kinjot’s five-minute tick. That is about 10.5 hours in all. A non-2xx status, a redirect, a timeout, a failed connection and a name that doesn’t resolve are all retried. An address that resolves somewhere private is not: that delivery fails at once.
- 410 Gone switches the webhook off at once. Answer 410 when you want deliveries to stop.
- After ten deliveries in a row fail their last attempt, the webhook switches off. A delivered one resets the count; tests never count.
- A switched-off webhook sends nothing. A delivery it was holding is cancelled if the webhook is still off when that delivery falls due; Re-enable before then and it is sent. Settings → Inbox says why, with Re-enable, and a line in the Inbox says so too.
- In Settings, a failed delivery can be resent: one more attempt, now, up to 3 times a delivery and for up to 20 deliveries an hour. A test is one attempt and is never retried; you can send 10 an hour.
- If Kinjot pauses delivery on its side, Settings shows the webhook as Paused. A delivery more than 24 hours overdue when delivery resumes is cancelled rather than sent late.