DOCS
Run and read results

Webhooks

When a run finishes, Testsy POSTs its result to your endpoint as signed JSON — for Linear, Jira, a dashboard or a bot of your own.

Add an endpoint#

An administrator adds endpoints under Integrations → Webhooks: an https address, whether to send every result or failures only, and for all projects or one. The signing secret (whsec_…) is shown once; keep it where your endpoint runs. Test sends a ping event; Turn off pauses an endpoint; Remove deletes it with its delivery log.

Webhooks are managed in the web app only. API keys and MCP cannot add, list or change them, so a leaked key cannot redirect your results.

What you receive#

One POST per finished run, Content-Type: application/json, with these headers:

HeaderMeaning
X-Testsy-Eventrun.finished, or ping from the Test button.
X-Testsy-DeliveryThe delivery id (whd_…); the same across retries, so you can ignore a duplicate.
X-Testsy-TimestampUnix seconds when this attempt was sent.
X-Testsy-Signaturev1=<hex>: HMAC-SHA256 of <timestamp>.<raw body> with your secret.
User-AgentTestsy-Webhooks/1

The body is the run exactly as the API's run view gives it:

{
  "event": "run.finished",
  "id": "whd_…",
  "at": "2026-10-24T06:10:12.000Z",
  "workspaceId": "o_…",
  "projectId": "p_…",
  "run": {
    "id": "j_…", "testId": "t_…", "testTitle": "Checkout works", "url": "https://shop.example.com",
    "mode": "cold", "status": "failed", "final": true, "verdict": "failed", "headline": "The total was wrong",
    "platform": "web", "createdAt": "…", "startedAt": "…", "finishedAt": "…", "durationMs": 119000, "error": null,
    "requirements": [{ "id": "c1", "requirement": "The total is right", "outcome": "fail", "reason": "Saw 12.00, expected 10.00" }],
    "captures": [{ "name": "total", "value": "12.00" }],
    "video": "https://app.testsy.dev/api/v1/runs/j_…/files/review/video.mp4",
    "screenshots": ["https://app.testsy.dev/api/v1/runs/j_…/files/shots/final.png"],
    "build": null,
    "link": "https://app.testsy.dev/o/o_…/runs/j_…"
  }
}

The video and screenshots addresses need an API key (Authorization: Bearer qa_…); link opens the run page. Messages the run received (emails, SMS) are never sent in a webhook.

Verify the signature#

Compute the HMAC over the raw body (not a re-serialised object) and compare in constant time; reject timestamps older than a few minutes.

import {createHmac, timingSafeEqual} from 'node:crypto';
export function verify(req, rawBody, secret) {
  const ts = req.headers['x-testsy-timestamp'], sig = req.headers['x-testsy-signature'] ?? '';
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = 'v1=' + createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  return want.length === sig.length && timingSafeEqual(Buffer.from(want), Buffer.from(sig));
}

Delivery and retries#

Answer 2xx within 10 seconds. Anything else — another status, a redirect, a timeout, no answer — is retried after 1, 5 and 30 minutes with the same delivery id. An endpoint that fails 20 deliveries in a row is turned off; turn it on again under Integrations once it is fixed. The last deliveries of each endpoint, with the status they got, are listed there. Delivery records are kept for 30 days.