Outbound webhooks

A webhook is Convo calling you. Point it at a URL you control and Convo sends a signed POST the moment feedback arrives, an item's status changes, or someone replies — so your own automation reacts to feedback without polling the API on a timer.

Webhooks are Convo's only outbound route, and they are deliberately generic — one of the ways Convo wires into your product. Convo does not bind to any particular tracker, so anything you can write an HTTP handler for is downstream of your feedback: an issue tracker, a Slack channel, a spreadsheet, a nightly digest of your own design.

Add an endpoint

Webhooks are configured per workspace, and only a workspace admin can see them — the configuration holds a signing secret, so the whole section is closed to everyone else.

Open workspace settings at /{workspace}/settings and find Webhooks.

  • Endpoint URL. Where Convo posts. It must be a full https:// address. The payload carries feedback text, and the signature proves the request came from Convo but does nothing to keep it private, so plain http is refused.
  • Events. Three checkboxes, all on by default. Uncheck the ones you do not want and this endpoint stops receiving them.
  • Signing secret. Generated in your browser as 32 random bytes, shown as 64 hex characters. Reveal it, copy it into the receiving service, and use Regenerate if you want a different one before saving. Unlike an API token it stays readable afterwards, because you need it to verify signatures and it never leaves your own workspace.

Choose Add webhook and deliveries begin immediately.

Each saved endpoint shows its URL, its events, its secret, and an Active or Paused switch. Pausing keeps the URL and the secret but stops deliveries — the delivery query only looks at active endpoints — which is the thing to do while your receiver is down. Deleting removes the secret with it.

You can add several endpoints. Each one is evaluated on its own, so a Slack relay and an issue-tracker bridge can subscribe to different events with different secrets.

The three events

item.created

Fires when a new piece of feedback lands, whatever path it came in by: the public page, the embedded widget, or submit_feedback over the API.

item.status_changed

Fires when an item's status actually changes, and carries oldStatus and newStatus alongside the item's current state. Setting an item to the status it already has changes nothing and sends nothing.

Every path that moves an item is covered — the maker view, the API, and your assistant over MCP — because delivery hangs off the stored item rather than off any one write path.

item.replied

Fires on every new reply, and carries replyId, authorKind and the reply body.

authorKind is person, maker, or agent. Note that maker and agent replies fire it too, which is the trap worth planning for: a receiver that posts replies back into Convo will hear its own reply and answer it again. Check authorKind before you write anything back.

What does not fire

The list is deliberately short, and everything absent from it is absent on purpose. Convo does not send an event for:

  • Tag changes — tag_item, or tagging in the maker view.
  • +1s — endorsement counts move constantly and would drown a receiver.
  • Title and body edits — update_item with no status in it.
  • Deletions — a removed item sends nothing.
  • Changelog entries and notifications — these follow a status change to shipped, so subscribe to item.status_changed and watch for newStatus: "shipped".

A status change bundled into an update_item call does fire, because the status genuinely changed. The rule is about the field, not the route.

What Convo sends

Every delivery is a POST with a JSON body in one envelope:

{
  "event": "item.status_changed",
  "workspaceId": "demo-ws",
  "data": {
    "workspaceId": "demo-ws",
    "productKey": "DEMO",
    "itemId": "DEMO-5",
    "title": "Bulk import from CSV",
    "status": "planned",
    "plusOneCount": 5,
    "oldStatus": "new",
    "newStatus": "planned"
  },
  "sentAt": 1788668085182
}

sentAt is epoch milliseconds. data always carries workspaceId, productKey, itemId, title, status and plusOneCount; item.status_changed adds oldStatus and newStatus, and item.replied carries replyId, authorKind and body in place of the item fields:

{
  "event": "item.replied",
  "workspaceId": "demo-ws",
  "data": {
    "workspaceId": "demo-ws",
    "productKey": "DEMO",
    "itemId": "DEMO-5",
    "replyId": "CNQ5sdoAKmU4f3kXWtKE",
    "authorKind": "maker",
    "body": "Good call — CSV import is on the roadmap now."
  },
  "sentAt": 1788668085203
}

The payload is a summary, not the whole item. It carries enough to route and label the event; when you need the body, the thread, or the people attached, read the item with get_item from the REST API.

Three headers come with it:

Header Value
content-type application/json
x-convo-event the event name, matching event in the body
x-convo-signature-v1 t=<unix seconds>,v1=<hex> — an HMAC-SHA256 of <t>.<raw body>, keyed with this endpoint's secret

Verify the signature

Anyone can post JSON at a URL. The signature is what tells you Convo did.

x-convo-signature-v1 carries two parts: t, the unix second Convo signed at, and v1, an HMAC-SHA256 over the string <t>.<raw body> keyed with your endpoint's secret. Rebuild that string, recompute the MAC, and compare. Three rules matter more than the algorithm:

  • Sign the bytes you received, not a re-serialized object. JSON.stringify(req.body) will not match — key order, spacing and unicode escaping all differ. Read the raw buffer.
  • Check t before you trust the MAC. Reject anything further than a few minutes from now — five is a good default. The timestamp is inside the signed string, so nobody can move it without breaking v1; that pairing is what stops a delivery captured off the wire from being replayed at you forever.
  • Compare in constant time. A plain === leaks how much of a guess was right, one byte at a time. Use crypto.timingSafeEqual.

A complete receiver, in Node with no dependencies:

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.CONVO_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

function verify(raw, header) {
  const parts = new Map();
  for (const part of String(header ?? "").split(",")) {
    const eq = part.indexOf("=");
    if (eq > 0) parts.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
  }
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1");
  if (!Number.isInteger(t) || !v1) return false;
  // Freshness first: a stale delivery is a replay, whatever its MAC says.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;
  const expected = createHmac("sha256", SECRET).update(`${t}.`).update(raw).digest();
  const got = Buffer.from(v1, "hex");
  return got.length === expected.length && timingSafeEqual(got, expected);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers["x-convo-signature-v1"])) {
      res.writeHead(401).end();
      return;
    }
    res.writeHead(204).end();
    handle(JSON.parse(raw.toString("utf8"))); // after responding — see below
  });
}).listen(4802);

In Express, reach for express.raw({ type: "application/json" }) on the webhook route so the body arrives as a Buffer; a global express.json() will have consumed and discarded the bytes you need.

Delivery semantics

Convo records every attempt and retries the failures that look temporary:

  • Three attempts at most, about five minutes apart. A delivery that fails with a 5xx, a timeout, or a connection error is picked up by a sweep that runs every five minutes and sent again, up to three attempts in total. A receiver that comes back within ten minutes still gets the event.
  • A 4xx is never retried. A 401, a 404 or a 422 is your receiver reading the request and refusing it, and sending the same body again only repeats the refusal. 429 counts as a refusal too: rate-limit Convo and the event is dropped rather than queued.
  • An 8-second timeout. A slower receiver is abandoned mid-request. That counts as a failure with no status code, so it is retried.
  • A history of the last 100 deliveries per endpoint. The Webhooks section shows each endpoint's last delivery — when it went, which event, and what came back — with a Recent deliveries disclosure for the ones before it. A failure still waiting on the sweep is marked as retrying; one that used all three attempts is marked failed and stays that way. Older records fall off as new ones arrive.
  • Every attempt is signed afresh. A retry carries the same event and the same data, but a new sentAt and a signature over the new body — so a receiver that rejects stale deliveries as replays still accepts one that arrives ten minutes late.
  • No replay button. The history is a record, not a queue: there is no way to re-send a delivery that has used its attempts. Reconcile from the REST API instead.

Three habits follow from that, and they are worth adopting before your first endpoint goes live.

  • Answer fast, work after. Verify the signature, return 204, then do the slow part — an outbound API call, a database write — outside the request. A receiver that opens a GitHub issue before replying is one slow issue-tracker away from a timed-out delivery.
  • Make handlers idempotent. Key your work on itemId plus event (plus newStatus for a status change, or replyId for a reply) and skip anything you have already handled. This is not optional once retries exist: a receiver that does the work and then takes longer than eight seconds to answer will be sent the same event again.
  • Reconcile if you cannot miss one. A webhook is a nudge, not a ledger. Three attempts cover a receiver that restarts, not one that is down for an hour. If something genuinely must not be lost, poll list_items or who_needs_followup on a schedule and treat the webhook as the fast path.

A recipe: open an issue when an item is planned

Bridge Convo to your issue tracker in about thirty lines. This one opens a GitHub issue the first time an item reaches planned, and ignores everything else.

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRET = process.env.CONVO_WEBHOOK_SECRET;
const REPO = process.env.GITHUB_REPO;
const seen = new Set();

function verified(raw, header) {
  const t = Number(/(?:^|,)\s*t=(\d+)/.exec(header ?? "")?.[1]);
  const v1 = /(?:^|,)\s*v1=([0-9a-f]+)/.exec(header ?? "")?.[1];
  if (!Number.isInteger(t) || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
  const expected = createHmac("sha256", SECRET).update(`${t}.`).update(raw).digest();
  const got = Buffer.from(v1, "hex");
  return got.length === expected.length && timingSafeEqual(got, expected);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", async () => {
    const raw = Buffer.concat(chunks);
    if (!verified(raw, req.headers["x-convo-signature-v1"])) {
      res.writeHead(401).end();
      return;
    }
    res.writeHead(204).end(); // answer first, then do the slow part

    const { event, data } = JSON.parse(raw.toString("utf8"));
    if (event !== "item.status_changed" || data.newStatus !== "planned") return;
    const key = `${event}:${data.itemId}:${data.newStatus}`;
    if (seen.has(key)) return;
    seen.add(key);

    const created = await fetch(`https://api.github.com/repos/${REPO}/issues`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
        accept: "application/vnd.github+json",
      },
      body: JSON.stringify({
        title: `[${data.itemId}] ${data.title}`,
        body: `Planned in Convo: https://convo.randomfact.com/${process.env.CONVO_WORKSPACE}/${data.productKey}\n\n+${data.plusOneCount} from customers.`,
      }),
    });
    console.log(`${data.itemId} -> issue ${(await created.json()).number}`);
  });
}).listen(4802);

Plan an item in the maker view and the issue appears:

DEMO-11 -> issue 101

Swap the fetch for a Slack incoming webhook and change the event filter to item.replied and you have the other common bridge. The shape is always the same: verify, answer, filter, act.

Testing against your laptop

An endpoint URL must be https://, and Convo cannot reach localhost in any case, so testing a receiver means giving it a public address. Run a tunnel — cloudflared tunnel --url http://localhost:4802, ngrok http 4802, or whatever you already use — and paste the tunnel's https:// URL into the Webhooks section.

Then choose Send test event on the endpoint. Convo posts one synthetic item.created — TEST-0, with "test": true in data so a receiver can tell it apart from real feedback — signed with that endpoint's secret, and reports the status code your receiver answered with right there in settings. It is the fastest way to catch the two mistakes fixtures never do: a body parser that ate the raw bytes, and a secret pasted with a stray space. The test send is recorded in the delivery history like any other, and is never retried.

After that, trigger something real. Posting on your own product's page fires item.created; pressing S on an item in the maker view fires item.status_changed.

Delete the endpoint when you are done, or pause it. A tunnel URL that has gone away is a webhook Convo tries three times on every event and records as failed — pausing an endpoint also stops the retries of anything already queued against it.

One way out, and only one

A webhook is one-way by design. Convo tells you something happened; what you do next is entirely yours, and Convo never hears back. Nothing outside Convo can move an item — a status change is a maker's call, made in the maker view, over the API, or through an assistant — so the loop that matters, ship, notify, changelog, closes on the item's own status and needs no receiver at all.

That makes webhooks the escape hatch rather than the mechanism. Reach for them when you want an event fanned out to several systems, when a piece of feedback should open a ticket in the tracker you already use, or when the thing you want to automate is not a tracker at all.

Where to go next

  • REST API — read the full item behind an event, and reconcile anything a delivery might have missed.
  • Getting started — the workspace and product that the events above belong to.