Webhooks
Signed event deliveries to your server. Add an endpoint, read the payloads, verify signatures and handle retries.
Add an endpoint
In Settings → API, under Webhooks, press Add endpoint. Enter the URL, pick Events, and press Add endpoint. Every event starts switched on. Only admins and owners see the section.
median webhooks add https://example.com/median/events \
--event message.created \
--event conversation.updatedcurl https://api.median.sh/v1/webhooks \
-H "Authorization: Bearer $MEDIAN_KEY" \
-H "content-type: application/json" \
-d '{"url":"https://example.com/median/events","events":["message.created","conversation.updated"]}'The API returns { "id": "..." }. The CLI sends every event when no --event is given. Change or remove an endpoint with Edit and Remove on its row, with median webhooks update and median webhooks remove, or with PATCH and DELETE /v1/webhooks/{id}. An events list sent to PATCH replaces the old list. Every flag is in the CLI reference.
| Rule | Value |
|---|---|
| URL | HTTPS on a public internet address, up to 512 characters, no credentials in it |
| Events | At least one, or no_events |
| Endpoints | 10 per organization, or too_many_endpoints |
| Median key | One must exist first, or missing_median_key |
| Who | Admins and owners |
Every surface refuses http://localhost, private addresses and URLs with credentials in them with invalid_url, because Median can't deliver to them. For local development, put a public HTTPS tunnel in front of your receiver.
Events
| Event | Dashboard label | Sent when |
|---|---|---|
message.created | New message | A message the customer can see lands, whether theirs, the agent's or a teammate's, on any channel. Outreach and the message sent when a reported bug or suggestion is done count too. |
conversation.created | New conversation | A conversation starts |
conversation.updated | Conversation updated | It is resolved, closed or reopened. A teammate takes over or hands it back. The agent hands it to the team. A tool call starts waiting for approval or review, or a review passes it to the team. |
bug.created | New bug | A bug is filed |
suggestion.created | New suggestion | A suggestion is filed |
signal.updated | Bug or suggestion updated | Status, priority, title or description changes. Another report joins it. A duplicate is merged into it. |
knowledge.suggestion.created | New knowledge suggestion | A knowledge suggestion enters the review queue |
A teammate reply that reopens a conversation or takes it over sends only message.created. Its conversation object carries the new state.
The picker groups them under Inbox, Bugs and suggestions and Knowledge. Internal notes, system lines, typing and replies still being written are never sent.
Payloads
Every delivery has the same envelope.
| Field | Type | Notes |
|---|---|---|
id | string | evt_ and 32 hex characters. The same on every retry. |
type | string | The event |
createdAt | number | When the event happened, in milliseconds |
data | object | Depends on the event, below |
The body is byte-identical on every attempt. Only the signature header changes.
Messages
{
"id": "evt_9f2c41d0a8b34e6f8c1d5a7b3e9f0c2d",
"type": "message.created",
"createdAt": 1754990004000,
"data": {
"conversation": { "id": "js7...", "status": "open", "awaitingHuman": false },
"message": {
"id": "jd3...",
"conversationId": "js7...",
"createdAt": 1754990004000,
"sender": "agent",
"agent": { "kind": "ai", "name": "Median AI", "avatarUrl": null },
"body": "Settings, then Export. It arrives as a zip.",
"attachments": []
}
}
}message has the shape of a message from the thread endpoint, without pending. sender is visitor or agent. agent.kind is ai or human, and agent is null on the visitor's own messages. Each attachment has name, size, type and a download url.
Conversations
{
"id": "evt_0b7d52e1c9a84f3d9e2a6b1c4d8f7e30",
"type": "conversation.updated",
"createdAt": 1754990010000,
"data": {
"conversation": { "id": "js7...", "status": "open", "awaitingHuman": true }
}
}conversation.created carries the same object. Messages carry it too.
| Field | Values |
|---|---|
status | open or resolved. A closed conversation reads as resolved. |
awaitingHuman | true while the conversation is open, the agent has asked for a person or a teammate has taken over, and the latest visible message is not from a teammate |
Signals
{
"id": "evt_1a2b3c4d5e6f708192a3b4c5d6e7f809",
"type": "bug.created",
"createdAt": 1754990004000,
"data": {
"signal": {
"id": "k57...",
"kind": "bug",
"title": "Checkout fails on Safari",
"body": "The pay button does nothing on Safari 18. Console shows a TypeError in `submitOrder`.",
"priority": "high",
"status": "open",
"reporterCount": 1,
"aliases": [],
"filedAt": 1754990004000,
"updatedAt": 1754990004000,
"updatedByName": null,
"linear": null,
"github": null
}
}
}suggestion.created and signal.updated carry the same signal object, which matches what the signals API returns.
| Field | Values |
|---|---|
kind | bug or suggestion |
status | open, planned, in progress, in review, done or declined |
priority | urgent, high, medium or low |
reporterCount | How many customers reported it |
linear | { identifier, url } of the linked Linear issue, or null |
github | { number, url } of the linked GitHub issue, or null |
Knowledge suggestions
{
"id": "evt_5c1e9a7b3d2f4e6a8b0c1d2e3f4a5b6c",
"type": "knowledge.suggestion.created",
"createdAt": 1754990020000,
"data": {
"suggestion": {
"id": "kx9...",
"conversationId": "js7...",
"conversationSubject": "Exporting data",
"title": "Export arrives as a zip",
"body": "Exports are sent as a zip file from Settings, then Export.",
"targetDocId": null,
"suggestedAt": 1754990020000
}
}
}targetDocId is the document the suggestion would change, or null for a new document.
Headers
| Header | Value |
|---|---|
content-type | application/json |
median-signature | t=<milliseconds>,v1=<hex> |
portal-signature | The same value under its old name. Ignore it. |
Verify the signature
v1 is the hex HMAC SHA-256 of `${t}.${body}`, keyed with the webhook secret derived from MEDIAN_KEY. medianSecret from @mediansh/agent-tools derives it.
- Read the raw body as text, before parsing it.
- Reject a
tmore than five minutes from your clock, to block replays. - Compute the HMAC and compare it in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
import { medianSecret } from "@mediansh/agent-tools";
export async function isFromMedian(body: string, header: string): Promise<boolean> {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=", 2)),
);
if (!parts.t || !parts.v1) return false;
if (Math.abs(Date.now() - Number(parts.t)) > 5 * 60_000) return false;
const secret = await medianSecret(process.env.MEDIAN_KEY!, "webhooks");
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${body}`)
.digest("hex");
const given = Buffer.from(parts.v1, "hex");
return (
given.length === expected.length / 2 &&
timingSafeEqual(given, Buffer.from(expected, "hex"))
);
}import { isFromMedian } from "@/lib/median-webhook";
export async function POST(request: Request) {
const body = await request.text();
const header = request.headers.get("median-signature") ?? "";
if (!(await isFromMedian(body, header))) {
return new Response("Bad signature", { status: 401 });
}
const event = JSON.parse(body);
await queueEvent(event); // your own queue, keyed on event.id
return new Response(null, { status: 204 });
}Which key signs
An endpoint added over the API with a Median key is signed with that key's webhook secret. One added from the dashboard, the CLI or with an OAuth token is signed with the newest Median key at that moment. With several keys, only that one verifies. Put that key in MEDIAN_KEY on the receiver.
Revoking that key replaces the endpoint's secret with a random value. Deliveries keep arriving and never verify. Remove the endpoint and add it again to sign with the newest key. See the API overview.
Delivery and retries
Answer with any 2xx within 15 seconds, DNS lookup included. Do slow work after answering. Any other status, a timeout, a redirect or a hostname that resolves to a private address is a failure. Redirects are never followed.
| Attempt | Delay after the previous one |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
- After the fifth failure the event is dropped.
- A new URL applies to retries already queued. Removing the endpoint cancels them.
- Revoking the signing key applies to the next attempt.
:::warning
Delivery is at least once and unordered. Dedupe on the event id and order by createdAt.
:::
Endpoint status
Each row in Settings → API shows one of these after its events.
| Row shows | Meaning |
|---|---|
| no deliveries yet | Nothing has been attempted |
| delivered and a time | The last attempt got a 2xx |
| failing since and a time | An attempt failed. The next 2xx clears it. |
An endpoint turns failing on the first failed attempt, while retries are still pending. GET /v1/webhooks returns the same state as lastDeliveryAt and failingSince. Logs records a Webhook delivery entry when an endpoint stops answering and another when it answers again. There is no test delivery and no per-delivery history.