Webhooks
Get a signed HTTP POST the moment a job in your project reaches a terminal state — no polling loop required.
Webhooks are configured per project. Once enabled, every job in that project that finishes (completed, failed, expired, or cancelled) triggers one delivery to your URL.
Setup
- Open the Dashboard, select your project, and go to its settings.
- In the Completion Webhook card, enter your Webhook URL (it must be a public HTTPS endpoint — private/internal addresses are rejected).
- Check Enabled and click Save webhook.
- Copy the signing secret shown after saving — it is displayed exactly once and cannot be retrieved later. If you lose it, check Regenerate signing secret on save and save again to mint a new one.
Payload
Your endpoint receives a JSON body:
{
"jobId": "b7f9c2e4-…",
"status": "completed",
"output": {
"success": true,
"passStatus": "pass",
"explanation": "Checkout flow worked end to end…",
"data": {},
"verdictConfidence": { "level": "high", "flags": [] }
},
"metadata": {
"costUsd": 4.5,
"durationSeconds": 512,
"createdIssues": [
{
"issueNumber": 42,
"issueUrl": "https://github.com/acme/shop/issues/42",
"title": "Coupon field rejects valid codes",
"isNew": true
}
]
}
}
Fields
| Field | Notes |
|---|---|
status | One of completed, failed, expired, cancelled. |
output | Present only when the job completed with a result; absent for failed / expired / cancelled. |
output.success | Legacy binary verdict, kept for back-compat. false means the AI judged the test failed or the job completed without a genuine verdict; true means a real pass, a not_applicable, or a legacy job from before verdicts existed. Prefer passStatus. |
output.passStatus | The first-class verdict: pass, fail, or not_applicable. Omitted when there is no verdict — see below. |
output.verdictConfidence | { level: "high" | "low", flags: [...] }. low means the verdict contradicted its own evidence — double-check before acting on it. Omitted on jobs graded before this signal existed. |
metadata.costUsd | What the job cost. |
metadata.durationSeconds | Test duration, when known. |
metadata.error | Failure detail, present on failed jobs. |
metadata.createdIssues | Tracker issues filed from this job’s findings, when auto-filing is on. |
passStatus is omitted, never null
When a job has no genuine verdict — it completed without being scored, or predates verdicts — the passStatus key is omitted from the JSON entirely. It is never sent as null.
// ❌ never matches — the key is absent, not null
if (payload.output.passStatus === null) { … }
// ✅ detects "no verdict"
if (payload.output.passStatus === undefined) { … }
if (!("passStatus" in payload.output)) { … }
Those no-verdict completions also report success: false, so a missing passStatus alongside success: false means unscored, not failed. When you need to distinguish the two, key your logic on passStatus.
Verifying the signature
Every delivery is signed with your project’s secret: the x-runhuman-signature header carries the hex-encoded HMAC-SHA256 of the raw request body. Verify it before trusting the payload:
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureHeader, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
Compute the HMAC over the exact raw bytes you received — re-serializing the parsed JSON can reorder keys and break the signature.
Respond with any 2xx status to acknowledge the delivery. Redirects are treated as failures — point the URL at the final endpoint.
Delivery and retries
Delivery is at-least-once:
- A failed delivery (non-2xx, timeout, transport error) is retried a few times within seconds.
- If your endpoint stays down — a deploy, a crash-recovery window — Runhuman keeps re-driving the delivery roughly every 10 minutes for up to 24 hours until your endpoint accepts it.
- Because retries can race an earlier success, you may occasionally see the same job twice. Deduplicate on
jobId: there is exactly one completion webhook per job.
Reconciliation: catching anything you missed
If your receiver was down longer than the retry horizon (or you just want a belt-and-braces audit), poll the jobs list with the updatedAfter cursor. A job’s updatedAt is bumped on its terminal transition, so — unlike a created-time filter — this also catches jobs that finished long after they were created:
curl -H "Authorization: Bearer $RUNHUMAN_API_KEY" \
"https://runhuman.com/api/jobs?projectId=$PROJECT_ID&status=completed,failed,expired,cancelled&updatedAfter=2026-07-14T00:00:00Z"
A robust consumer loop:
- Store a cursor (start it at “now” when you first enable the webhook).
- Periodically — and on every restart of your own service — request jobs with
updatedAfter=<cursor>and the terminal statuses above. - Process anything you haven’t seen (dedupe on job
id), then advance the cursor to the maximumupdatedAtyou received.
The endpoint is the standard jobs list, so pagination (limit/offset) and the other filters compose with the cursor.