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

  1. Open the Dashboard, select your project, and go to its settings.
  2. In the Completion Webhook card, enter your Webhook URL (it must be a public HTTPS endpoint — private/internal addresses are rejected).
  3. Check Enabled and click Save webhook.
  4. 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

FieldNotes
statusOne of completed, failed, expired, cancelled.
outputPresent only when the job completed with a result; absent for failed / expired / cancelled.
output.successLegacy 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.passStatusThe 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.costUsdWhat the job cost.
metadata.durationSecondsTest duration, when known.
metadata.errorFailure detail, present on failed jobs.
metadata.createdIssuesTracker 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:

  1. Store a cursor (start it at “now” when you first enable the webhook).
  2. Periodically — and on every restart of your own service — request jobs with updatedAfter=<cursor> and the terminal statuses above.
  3. Process anything you haven’t seen (dedupe on job id), then advance the cursor to the maximum updatedAt you received.

The endpoint is the standard jobs list, so pagination (limit/offset) and the other filters compose with the cursor.