# Signed deploy hooks and webhooks

Source: https://testwatch.dev/docs/deploy-hooks-and-webhooks
Updated: 2026-10-17

A deploy hook lets your deploy tool start a run with one signed POST, no API key needed. Webhook alert channels send you signed JSON when a run’s verdict is final. Both are on Starter and up.

## Deploy hooks (inbound)

Create a hook on the app’s **CI & deploys** page (owners and admins). Choose the provider, **Generic** or **Netlify**, and optionally the environment and tags to run. You get the hook URL and a signing secret starting with `twhk_`. The secret is shown once; you can rotate it. An app can have up to 20 hooks.

The call needs no API key. It is authenticated only by its signature over the raw request body. An unknown hook, a disabled hook and a bad or stale signature all get the same `401`, so the answer never tells an outsider which hooks exist.

### Generic hooks (HMAC)

Send two headers:

- `X-TestWatch-Timestamp`: the current time in Unix seconds. It must be within 5 minutes of TestWatch’s clock.
- `X-TestWatch-Signature`: `v1=` followed by the hex HMAC-SHA256 of the string `<timestamp>.<raw body>`, keyed with the hook secret. While you rotate a secret you can send several `v1=` values separated by commas; one match is enough.

```
BODY='{"environmentUrl":"https://staging.example.com","commitSha":"'"$GIT_SHA"'","deployId":"'"$DEPLOY_ID"'"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$TESTWATCH_HOOK_SECRET" | sed 's/^.* //')
curl -X POST "$TESTWATCH_HOOK_URL" \
  -H 'Content-Type: application/json' \
  -H "X-TestWatch-Timestamp: $TS" \
  -H "X-TestWatch-Signature: v1=$SIG" \
  -d "$BODY"
```

The body is optional. When present it is JSON with any of these fields:

- `environmentUrl`: the deployed URL. It picks the environment by its URL or preview patterns, and a preview URL becomes a [preview run](https://testwatch.dev/docs/preview-urls-and-protection).
- `environment`: an environment id or name.
- `deployId`: your deploy’s id. Re-sending the same one never starts a second run.
- `commitSha`, `branch`, `pullRequest`, `repo` (`owner/name`), `ciUrl`: shown on the run.

Without a URL or environment in the body, the run uses the hook’s environment, else the app’s default. Without a `deployId`, a re-sent identical call (same timestamp and body) maps to the same run, and the next deploy is a new run. A started run answers `202` with `runId` and `url`.

### Netlify hooks (JWS)

In Netlify, add an outgoing webhook deploy notification, paste the hook URL, and set its JWS secret token to the hook secret. Netlify then signs each call with an `X-Webhook-Signature` header, and TestWatch checks it against the body.

- Only a deploy in the `ready` state starts a run. Other states answer `202` with the reason they were ignored.
- The run targets the deploy’s alias, branch or deploy URL, whichever first matches one of the app’s environments or preview patterns. A production deploy that matches nothing falls back to the hook’s environment. Anything else is ignored with the reason, never run against a guessed target.
- The commit, branch and pull request are taken from Netlify’s payload.

### Limits and errors

- Each hook accepts 10 signed calls a minute; more answer `429`.
- A plan without API access answers `402 plan_limit`. A suspended workspace answers `403`.
- The hook’s row in the dashboard shows when it was last called and what happened.

## Webhook alert channels (outbound)

In **Settings → Notifications**, add a channel of type **Webhook** with an https URL. You get a signing secret starting with `whsec_`, shown once and rotatable. Then add a rule that sends events to it. The event `run.verdict`, sent when a run’s verdict becomes final, goes only to webhook channels, so bots and CI dashboards can react to the same answer CI gets.

Other event types are `run.closed`, `app.unreachable`, `real_bug`, `heal.review`, `gen.completed`, `schedule.failed`, `fix.promoted` and `digest.weekly`, plus `test` from the “Send test” button. Every body has the same envelope, and run events carry this `data`:

```
{
  "type": "run.verdict",
  "timestamp": "2026-10-17T09:00:00.000Z",
  "data": {
    "workspaceId": "...",
    "app": { "id": "...", "name": "Shop" },
    "run": {
      "id": "...",
      "status": "failed",
      "trigger": "api",
      "verdict": "real_bug",
      "final": true,
      "environment": "Staging",
      "counts": { "total": 12, "passed": 11, "failed": 1 },
      "ci": { "commitSha": "4f2a9c1", "branch": "main" },
      "preview": false,
      "targetUrl": null,
      "branchKey": null,
      "startedAt": "...",
      "finishedAt": "...",
      "url": "https://app.testwatch.dev/apps/.../runs/..."
    },
    "failures": ["Checkout with a saved card"]
  }
}
```

### Verifying the signature

Messages are signed per the [Standard Webhooks](https://www.standardwebhooks.com/) spec, so any Standard Webhooks library can verify them. The headers:

- `webhook-id`: the message id. It stays the same when a delivery is retried, so you can drop duplicates.
- `webhook-timestamp`: Unix seconds. Reject old ones to block replays.
- `webhook-signature`: `v1,` followed by the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<raw body>`. The key is the base64-decoded part of the secret after `whsec_`.

Without a library, in Node:

```
import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body exactly as received (a string, before JSON.parse).
export function verifyTestWatch(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
  return String(headers['webhook-signature'] ?? '')
    .split(' ')
    .some((part) => {
      const [version, sig = ''] = part.split(',');
      const a = Buffer.from(sig);
      const b = Buffer.from(expected);
      return version === 'v1' && a.length === b.length && timingSafeEqual(a, b);
    });
}
```

### Delivery

- Answer with a 2xx within 10 seconds. Redirects are not followed.
- 429, 5xx, network and DNS errors are retried. Other answers count as failures, and a channel that keeps failing is turned off; the dashboard shows why.
- The test-data [cleanup webhook](https://testwatch.dev/docs/fixtures#cleanup) is signed the same way.
