# Webhooks

> Receive job and task events, verify signatures and recover failed deliveries.

Source: https://workroad.app/docs/api/webhooks

## Set up a receiver

A webhook sends a signed `POST` to your server when a job ends or a task comes due, so an integration can stop polling `GET /jobs/{jobId}`. Create one in **Settings → API keys**, or with `POST /webhooks` using a key with `webhooks:manage`. Creating a webhook, changing its URL and rotating its secret are not MCP tools, so an agent can't choose where events go. A user can have at most 5 webhooks; a sixth returns 409 `conflict` with `details.reason` `webhook-quota`.

Webhooks belong to the user, not to the key that created them. They fire for every job the user starts, whether from the app, the API or the MCP server, and they keep firing after the key that created them is revoked.

The URL must use `https`, must not contain a username or password, and must be at most 2048 characters. `localhost`, names ending in `.localhost`, `.local` or `.internal`, and private, loopback and link-local IP addresses are refused with 400 `invalid_request`. A host that resolves to a private address, or doesn't resolve within 3 seconds, is also refused on create (400 `invalid_request`, `details.reason` `private-address` or `unresolvable-host`). WorkRoad checks every DNS answer again before forwarding. Hooker then resolves the hostname independently and permits private ranges, so this preflight check cannot prevent DNS rebinding at the relay. Deployments must restrict all eligible relays' egress to public destinations and deny access to sensitive private networks.

`POST /webhooks` returns the signing secret, and no other response does, so store it then. `POST /webhooks/{webhookId}/rotate` returns a new secret. The old secret keeps working for 24 hours, so you can update your receiver without dropping events. During that time each delivery, retries of earlier events included, carries two signatures in `webhook-signature`, and a Standard Webhooks library accepts either secret. Rotating a second time inside the 24 hours ends the first old secret at once, and only the newest previous secret is kept. To shorten the window, send `{ "previousSecretExpiresInSeconds": 3600 }`, any whole number from 0 to 86400. Send `0` to revoke the old secret immediately, for example when it leaked.

These two operations answer 400 `invalid_request` to a request that carries an `Idempotency-Key`. A stored replay would keep the secret on WorkRoad's servers for 24 hours, so they don't store responses at all. That makes a retry unsafe: if `POST /webhooks` timed out, call `GET /webhooks` before trying again, because a second create makes a second webhook with its own secret, and both count toward the limit of 5. A rotate you retry rotates again: the secret from the response you never saw becomes the old secret, and the one before it stops verifying at once. Use the secret from the last response.

## Events

| Event           | When                                                                                                                        |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `job.succeeded` | A job ends as `succeeded`. A note generation stopped after writing some sections ends here too, with `result.stopped` true. |
| `job.failed`    | A job ends as `failed`, including one WorkRoad gave up on after it stopped responding.                                      |
| `job.cancelled` | A job ends as `cancelled`, whether you or WorkRoad cancelled it.                                                            |
| `task.due_soon` | An unfinished task is due within 24 hours. It is sent once per due date, so moving the due date sends it again.             |
| `webhook.test`  | Only from `POST /webhooks/{webhookId}/test`, whatever events the webhook subscribes to.                                     |

The job events cover the jobs `GET /jobs/{jobId}` can show: copilot replies, generations, practice test generation and grading, and file parses.

Events are thin. They carry ids and the state that changed, never content:

```json
{
   "id": "6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
   "type": "job.succeeded",
   "timestamp": "2026-01-15T09:31:12.000Z",
   "data": {
      "job": {
         "id": "7d6c5b4a-3f2e-4d1c-8b0a-9f8e7d6c5b4a",
         "kind": "note_generation",
         "status": "succeeded",
         "finishedAt": "2026-01-15T09:31:10.000Z"
      }
   }
}
```

A `task.due_soon` event has `"data": { "task": { "id": "...", "dueDate": "..." } }`. Fetch the rest with `GET /jobs/{jobId}` or `GET /tasks/{taskId}`. Those calls apply the calling key's scopes, so a webhook never shows a key more than the key could read.

`id` is the delivery's id, the same value as the `webhook-id` header. `timestamp` is when WorkRoad recorded the event. Delivery is at least once: the same delivery can arrive more than once, for example when your server handled it but answered after the timeout, and deliveries can arrive out of order. Retries keep the same `webhook-id`, so store the ids you have handled and skip any you have already seen.

## Headers

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any Standard Webhooks library can verify them.

| Header              | Value                                                                                 |
| ------------------- | ------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                    |
| `User-Agent`        | `Workroad-Webhooks/1`                                                                 |
| `X-Workroad-Event`  | The event type, such as `job.succeeded`.                                              |
| `webhook-id`        | The delivery id, the same as the body's `id`. Retries of a delivery keep it.          |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds. Each retry gets a new one.             |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256 signature. Several signatures are separated by spaces. |

## Verifying the signature

The secret looks like `whsec_` followed by base64. Pass it to a Standard Webhooks library as it is, together with the raw request body and the headers. Verify before you parse the JSON: parsing and re-serializing changes the bytes, and the signature covers the exact body WorkRoad sent. The libraries reject a `webhook-timestamp` more than 5 minutes from your clock, so a captured delivery can't be replayed later.

In JavaScript or TypeScript, install `standardwebhooks` from npm:

```ts
import { Webhook } from "standardwebhooks";

const secret = process.env.WORKROAD_WEBHOOK_SECRET;
if (secret === undefined) throw new Error("WORKROAD_WEBHOOK_SECRET is not set");
const webhook = new Webhook(secret);

// A route handler that receives a standard Request.
export const POST = async (request: Request): Promise<Response> => {
   const body = await request.text();
   let event: unknown;
   try {
      event = webhook.verify(body, Object.fromEntries(request.headers));
   } catch {
      return new Response("Invalid signature", { status: 401 });
   }
   // Queue the event for processing, then answer quickly.
   return new Response(null, { status: 204 });
};
```

In Python, install `standardwebhooks` from PyPI. This example uses Flask:

```python
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["WORKROAD_WEBHOOK_SECRET"])

@app.post("/workroad")
def workroad_webhook():
    try:
        event = webhook.verify(request.get_data(as_text=True), dict(request.headers))
    except WebhookVerificationError:
        return "Invalid signature", 401
    # Queue the event for processing, then answer quickly.
    return "", 204
```

Without a library, the check takes a few steps:

1. Decode the base64 after `whsec_` to get the key bytes. The key is those bytes, not the text of the secret.
2. Refuse a `webhook-timestamp` more than 5 minutes from your clock.
3. Compute the HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<raw body>` with that key and base64-encode it.
4. Split `webhook-signature` on spaces. Accept the delivery if any `v1,` entry matches, comparing in constant time.

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

export const verifyWorkroadWebhook = (secret: string, headers: Headers, body: string): boolean => {
   const id = headers.get("webhook-id") ?? "";
   const timestamp = headers.get("webhook-timestamp") ?? "";
   if (!/^\d{1,15}$/.test(timestamp)) return false;
   if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 5 * 60) return false;
   const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
   const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
   return (headers.get("webhook-signature") ?? "").split(" ").some((entry) => {
      const [version, signature = ""] = entry.split(",");
      const actual = Buffer.from(signature, "base64");
      return (
         version === "v1" && actual.length === expected.length && timingSafeEqual(actual, expected)
      );
   });
};
```

## Retries and disabling

Answer with any `2xx` status within 10 seconds. Anything else counts as a failed attempt: another status, a redirect, a timeout or a connection error. WorkRoad doesn't follow redirects, so do slow work after you answer.

WorkRoad retries a failed event delivery after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours, at the earliest. For a single failing delivery, the eighth attempt comes about 45 hours after the first. If it fails too, the delivery is marked `failed` and never sent again.

Deliveries to one webhook go out one at a time. While a webhook is failing, WorkRoad sends it about one probe a minute while it has a backlog of due deliveries, plus any test you queue. The other queued events wait, so they can be sent later than the schedule above says, and some may end as `endpoint-disabled` when the 72-hour disable below lands. The first successful delivery ends the failing run, and the queue drains at the normal pace again. A slow or broken receiver can't delay other users' deliveries: it holds at most one of the six workers that send webhooks.

WorkRoad disables a webhook when an event delivery to it fails and no delivery has succeeded since a failure at least 72 hours earlier. A quiet spell with no events does not reset that clock; only a successful delivery does. A disabled webhook gets no new events, and `GET /webhooks` shows `status: disabled`. Its queued deliveries fail with `endpoint-disabled` as each comes due. To start again, call `POST /webhooks/{webhookId}/enable`. The secret stays the same. Retries that were not due yet when you enable it go out on their schedule, but `endpoint-disabled` deliveries stay failed. Events that arrived while the webhook was disabled were never recorded, so they are not sent. The exception is an event from the last 2 minutes before you enable it, which can still reach the webhook. Before that point, one successful delivery ends the failing run and `status` goes back to `active`. `lastDeliveryAt` and `lastStatus` on the webhook describe the last successful delivery only.

`GET /webhooks/{webhookId}/deliveries` lists deliveries newest first, 25 a page by default (`limit` sets the page size, and `nextCursor` fetches the next page), with each one's status, attempts, last HTTP status and error. `attempts` counts the POSTs made so far, so a delivery that succeeded on the first try shows 1. `POST /webhooks/{webhookId}/test` queues a `webhook.test` event so you can check your receiver end to end. A test is attempted once: if it fails, it is marked `failed` straight away, without retries, and it never counts toward disabling the webhook. A webhook has at most one pending test: another test request returns 409 `conflict` with `details.reason` `webhook-test-pending` until the first has finished its attempt. On a webhook that isn't failing, events and tests usually go out within about a minute.

## Delivery relay details

Normal and test deliveries are forwarded through the Hooker relay. Requests to your receiver originate from the relay's IPs, not WorkRoad's servers. Verify the [Standard Webhooks signature](#verifying-the-signature) to authenticate them. WorkRoad signs the exact body before forwarding and manages delivery retries and endpoint state. Hooker stores that body for its configured retention window, by default 7 days, and keeps event metadata and attempt history for 30 days by default.

The relay call waits up to 10 seconds for the receiver's result. If Hooker returns an accepted receipt, WorkRoad polls the event state and latest attempts for the receiver's status. An unfinished poll leaves the delivery pending with `relay-pending`; a relay outage leaves it pending with `relay-error`. These reschedule the same forward using its idempotency key, without another receiver attempt or any change to the webhook's failing run, hold or disabled state. Receiver failures count only after a confirmed attempt. Non-2xx deliveries may show no status because Hooker drops the status on HTTP error attempts. WorkRoad recovers it from error details when available; connection errors and receiver timeouts may also have no status. Polling backs off from 2 to 8 seconds between reads. A delivery still deferred 72 hours after its first deferral fails with `relay-expired`, without counting a receiver attempt or changing endpoint failure state. Lease recovery may produce later attempts, whose confirmed receiver results are recognized. Terminal relay events with no receiver result fail the delivery without penalizing the endpoint. Tests still get one confirmed receiver attempt.
