Median

Webhooks

Signed event deliveries to your server. Add an endpoint, read the payloads, verify signatures and handle retries.

Updated Oct 1, 20264 minute read

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.updated
curl 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.

RuleValue
URLHTTPS on a public internet address, up to 512 characters, no credentials in it
EventsAt least one, or no_events
Endpoints10 per organization, or too_many_endpoints
Median keyOne must exist first, or missing_median_key
WhoAdmins 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

EventDashboard labelSent when
message.createdNew messageA 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.createdNew conversationA conversation starts
conversation.updatedConversation updatedIt 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.createdNew bugA bug is filed
suggestion.createdNew suggestionA suggestion is filed
signal.updatedBug or suggestion updatedStatus, priority, title or description changes. Another report joins it. A duplicate is merged into it.
knowledge.suggestion.createdNew knowledge suggestionA 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.

FieldTypeNotes
idstringevt_ and 32 hex characters. The same on every retry.
typestringThe event
createdAtnumberWhen the event happened, in milliseconds
dataobjectDepends on the event, below

The body is byte-identical on every attempt. Only the signature header changes.

Messages

message.created
{
  "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

conversation.updated
{
  "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.

FieldValues
statusopen or resolved. A closed conversation reads as resolved.
awaitingHumantrue 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

bug.created
{
  "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.

FieldValues
kindbug or suggestion
statusopen, planned, in progress, in review, done or declined
priorityurgent, high, medium or low
reporterCountHow 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

knowledge.suggestion.created
{
  "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

HeaderValue
content-typeapplication/json
median-signaturet=<milliseconds>,v1=<hex>
portal-signatureThe 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.

  1. Read the raw body as text, before parsing it.
  2. Reject a t more than five minutes from your clock, to block replays.
  3. Compute the HMAC and compare it in constant time.
lib/median-webhook.ts
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"))
  );
}
app/api/median/events/route.ts
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.

AttemptDelay after the previous one
1Immediately
230 seconds
35 minutes
430 minutes
52 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 showsMeaning
no deliveries yetNothing has been attempted
delivered and a timeThe last attempt got a 2xx
failing since and a timeAn 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.

Still need help?

    Esc