Outgoing webhooks
Last updated
A webhook sends a signed JSON request to your own endpoint when something happens in an app: a problem report, a broken protected flow, a case opened or closed, a study booked. Use it to start your own workflows, or to feed a tool Metrickle doesn't post to directly. Slack and Microsoft Teams are set up the same way and get a formatted message instead. See integrations for those.
Add a webhook
- Open the app's Integrations page and choose Add Slack, Teams or webhook under Notify. You need to be an owner or admin, or have the
integrations.managepermission. - Choose Webhook, give it a name, paste your endpoint's URL and pick the events to send.
- Copy the signing secret (
whsec_…). It's shown once. - Send a test delivery to check your endpoint answers.
The URL must start with https:// and use a public host name, not localhost or an IP address. An app can have up to 20 destinations.
The request
Each delivery is a POST with a JSON body:
POST /your/endpoint
content-type: application/json
user-agent: Metrickle-Webhooks/1
metrickle-event: feedback.created
metrickle-delivery: evt_…
metrickle-signature: t=1790841234000,v1=5f2c…
{
"id": "evt_…",
"type": "feedback.created",
"createdAt": 1790841234000,
"app": { "id": "app_…", "name": "Orbit Web" },
"url": "https://app.metrickle.com/apps/app_…/feedback",
"data": {
"feedbackId": "…",
"category": "accessibility",
"accessibilityBarrier": true,
"message": "The pay button can't be reached with Tab",
"path": "/checkout",
"a11y": ["keyboard"]
}
}
| Field | Meaning |
|---|---|
id | A unique id for this delivery, the same as the metrickle-delivery header |
type | The event type, the same as the metrickle-event header |
createdAt | When it was sent, in Unix milliseconds |
app | The app it's about |
url | Where to look in the dashboard |
data | The event's details (below) |
Check the signature
metrickle-signature is t=<unix ms>,v1=<hex>. The hex is an HMAC-SHA256, keyed with your signing secret, of the timestamp, a full stop and the raw request body. Check it on every request, and reject requests more than five minutes old:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, header: string, secret: string): boolean {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() - Number(t)) > 5 * 60_000) return false; // reject replays
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return typeof v1 === "string" && v1.length === expected.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Use the body exactly as it arrived. Parsing and re-encoding the JSON changes it and the check fails.
Responses and retries
Any 2xx response within 5 seconds counts as delivered. A network error, a timeout, a 408, a 429 or a 5xx is tried twice more, after 1 second and then 4 seconds. Other responses aren't retried. Each destination's delivery log, on the Integrations page, shows the last 50 deliveries and what your endpoint answered.
Event types
| Type | Sent when | data fields |
|---|---|---|
feedback.created | Someone sends a problem report or other feedback, with accessibility barriers called out | feedbackId, category, accessibilityBarrier, message, rating, path, url, platform, appVersion, device, a11y, hasScreenshot, source |
alert.task_success_drop | A task's success rate over the last 24 hours fell well below its 7-day baseline | taskId, taskName, appId, current, baseline, worstSegment, abandonedAt |
alert.new_hotspot | An element or form started causing friction in the last 24 hours | signal, path, target, label, sessions, events |
alert.contract_broken | A protected flow broke: people using an accessibility setting fall clearly behind people using none since the latest release | taskId, taskName, appId, verdict, segment, baseline, comparedWith, release, window |
case.opened | A case opened on a broken task: the step, element and group of people that lose most, and what it costs | caseId, taskId, taskName, status, finding, baseline, recordings, openedBy |
case.closed | A case closed: its fix verified for the people it broke for, or dismissed | The case.opened fields, plus resolution (verified or dismissed), after, closedAt, closeNote |
issue.measured | 7 days after a linked issue is closed: the measure before and after the fix | issueKey, issueUrl, title, improved, before, after |
study.booked | Someone booked, moved or cancelled a moderated research session | change (booked, moved or canceled), studyId, participantId, startsAt, endsAt, hasAccessNeeds |
study.completed | A participant finished an unmoderated test, or a moderator wrapped up a session | studyId, participantId, kind, tasksDone |
Protected flows are called contracts in the API, which is why the type is alert.contract_broken.
alert.task_success_drop and alert.new_hotspot also carry latestRelease (version, at, url) when there was a release in the 2 days before, since it's often the cause.
For feedback.created, source is { "label", "url" } when the report came from a connected support inbox, such as Zendesk or Intercom, and null for reports sent in the app.
A test delivery has the type test and data of { "message": "This is a test delivery." }.
How often alerts are sent
Alerts are checked hourly. Each task alert is sent at most once a day, and each friction hotspot at most once a week.