The shape of it
- Each live app can have a private URL, like
https://qarunbook.com/api/integrations/webhook/k3x9…. - Paste it into the tool as a webhook. Each POST it sends becomes an issue on the app, and the answer carries its code.
- The body can be in our own shape, or in the tool's: pick a preset for Bugsnag, Rollbar or a Datadog monitor, or type the paths to the fields you want.
- The same error sent again while its issue is open is not raised twice.
It is the same issue as one raised in the app, so everything else follows: it is filed in Linear, Jira, GitHub or GitLab if the app is connected, Slack hears of it, and it counts towards Free's monthly allowance of issues. The webhook is on every plan.
If your code can set an Authorization header and you want checks, results and lists too, use the REST API instead. The webhook is for tools that can only POST to a URL.
Turn it on
- Open the app, choose Configure, then the Integrations tab, and find Incoming webhook. You need to be an admin of the app.
- Pick the payload the tool sends, and choose Turn on. The URL appears; Copy it into the tool.
The webhook is for apps in live mode, where a bug does not need a check — a monitoring tool cannot say which check failed. Switch the app to live under Settings first. If it goes back to testing, deliveries are refused with 409 not_live.
Our own shape
Only title is needed. Everything else is optional:
title— the first line of the issue. Without one, the first line ofdescriptionis used.description— the rest of it.platforms— the platforms it affects: a list or a comma-separated string of the app's codes or names.iosorandroidwork when the app has one platform of that kind. Words that match nothing, likeproduction, are ignored, and the card's default applies.priority—urgent,high,mediumorlow. Severities are understood too:criticalandfatalare urgent,erroris high,warningmedium,infolow, and Datadog'sP1–P5and Rollbar's numeric levels map the same way. Anything else gets the card's default.url— the page it happened on, added to the issue's text.reporter—{ name, email? }, someone outside the team. The issue reads Ada Lovelace via webhook and the REST API returns her asreporter.external_id— the tool's own id for the problem, for dedupe.link— a link back to it in the tool, shown on the issue.httpandhttpslinks only.source— a short name for the tool, kept with the issue. Presets fill it in themselves.
curl -X POST "https://qarunbook.com/api/integrations/webhook/<token>" \
-H "Content-Type: application/json" \
-d '{
"title": "Checkout button does nothing",
"description": "Tapping Pay shows a spinner forever.",
"platforms": ["IOS"],
"priority": "high",
"url": "https://shop.example.com/checkout",
"reporter": { "name": "Ada Lovelace", "email": "ada@example.com" },
"external_id": "ticket-4812",
"link": "https://support.example.com/tickets/4812"
}'HTTP/1.1 201 Created
{ "issue": { "id": "m1x…", "code": "QA-41", "url": "https://qarunbook.com/?app=…&bug=…" } }JSON is read whatever the Content-Type says, since some tools send text/plain. A form post works too, as its fields, or with the JSON in a payload field.
Presets
A preset knows where a tool keeps the title, the id, the link and the severity in its own webhook. Anything it does not find falls back to our shape, so the fields above still work alongside it.
Bugsnag
In Bugsnag, open the project's Integrations and email settings, add a Webhook, paste the URL, and choose which events notify it. The title is the error class and message; the error's id dedupes, its page in Bugsnag is the link, and its severity is the priority. See Bugsnag's webhook docs.
Rollbar
In Rollbar, open the project's Notifications, add the Webhook channel with the URL, and pick the rules — New item and Reactivated item are the useful ones. The item's title, id, link and level are read; every occurrence of one item dedupes onto one issue. See Rollbar's webhook docs.
Datadog monitors
In Datadog, add a webhook in the Webhooks integration with the URL, and use this payload — the default one plus three fields. Then mention @webhook-qarunbook (or whatever you named it) in a monitor's message. The aggregation key is the same for an alert and its recovery, so they land on one issue. See Datadog's webhook docs.
{
"title": "$EVENT_TITLE",
"body": "$EVENT_MSG",
"id": "$ID",
"aggreg_key": "$AGGREG_KEY",
"link": "$LINK",
"priority": "$ALERT_PRIORITY"
}Any other payload
Choose Custom mapping and type where each field is, as a dot path into the JSON. Lists take an index: items[0].title or items.0.title. Two more forms help with real payloads:
error.errorId|error.id— the first of these that has a value.{error.class}: {error.message}— a template. Placeholders are filled in, a line whose placeholders are all empty is left out, and stray separators at the ends are trimmed.
// A payload from some tool…
{
"event": "error.created",
"data": {
"error": { "id": "e_93", "message": "Payment failed", "level": "critical", "os": "Android" },
"permalink": "https://monitor.example.com/e_93"
}
}
// …and the paths that read it, typed into the card:
Title data.error.message
Description Level {data.error.level} on {data.error.os}
Link back data.permalink
External id data.error.id
Priority data.error.level
Platform data.error.osDedupe
Error trackers send the same error more than once — a spike, a retry, a reopened item. When a delivery carries an external id and an issue from the same source with that id is still open, nothing new is raised: the answer is 200 with "duplicate": true and the existing issue. Once that issue is marked fixed, the next delivery raises a new one, because the error is back.
HTTP/1.1 200 OK
{ "duplicate": true, "issue": { "id": "m1x…", "code": "QA-41", "url": "https://qarunbook.com/?app=…&bug=…" } }Without an external id, every delivery is its own issue.
Signing
Anyone who has the URL can raise a bug — that is what lets a tool with no settings for keys use it. If the tool can sign what it sends, add a signing secret on the card: Make a secret, or paste the one your tool gave you. From then on a delivery must carry
X-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the secret>and anything without it, or with the wrong one, is refused with 401 invalid_signature. Sign the exact bytes you send — re-encoding the JSON after signing changes them.
BODY='{"title":"Checkout button does nothing"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$QARUNBOOK_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "https://qarunbook.com/api/integrations/webhook/<token>" \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIG" \
-d "$BODY"import { createHmac } from "node:crypto";
const body = JSON.stringify({ title: "Checkout button does nothing" });
const signature = "sha256=" + createHmac("sha256", process.env.QARUNBOOK_WEBHOOK_SECRET).update(body).digest("hex");
await fetch(process.env.QARUNBOOK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Signature": signature },
body, // exactly the string that was signed
});Regenerate URL makes a new one and stops the old one at once, for when it has ended up somewhere it should not be. Turn off refuses everything until you turn it back on; the URL stays the same.
Hearing back when it is fixed
Give the card a Notify URL, and when a bug that came in through the webhook is marked fixed — from any door: the app, MCP, the API, a merged pull request, Linear or Jira — or its fix is verified, we POST it there, signed with the same secret in the same header. Use it to resolve the error in the tool it came from. It is sent once, with a five-second timeout, and redirects are not followed. The notify URL must be public HTTPS, and needs a signing secret.
POST https://hooks.example.com/qarunbook
Content-Type: application/json
X-Qarunbook-Event: issue.fixed
X-Signature: sha256=…
{
"event": "issue.fixed",
"at": "2026-10-05T09:12:00.000Z",
"app": { "id": "…", "name": "Checkout" },
"issue": {
"id": "m1x…", "code": "QA-41", "url": "https://qarunbook.com/?app=…&bug=…",
"external_id": "5f3e9a", "source": "bugsnag", "link": "https://app.bugsnag.com/…",
"status": "fixed", "fixed_by": "Sam", "fixed_at": "…", "verified_by": null, "verified_at": null
}
}Answers
201— raised.{ issue: { id, code, url } }.200— already open, see dedupe.400 invalid_json— the body is not a JSON object.400 missing_title— nothing to call the issue.401 invalid_signature— the app has a signing secret and the signature is missing or wrong.402 limit_reached— the workspace has used this month's issues on Free.404 not_found— no webhook at that URL: it is off, was replaced, or never existed.409 not_live— the app is not in live mode.409 in_progress— the same external id is being raised by another delivery at this moment; retry.413 too_large— the body is over 1 MB.429 rate_limited— over 120 deliveries this hour to one app. Wait for the hour to turn.
Errors are { error: { code, message } }, the same as the REST API's.