Skip to content
WorkRoad home
REST API

Webhooks

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

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

EventWhen
job.succeededA job ends as succeeded. A note generation stopped after writing some sections ends here too, with result.stopped true.
job.failedA job ends as failed, including one WorkRoad gave up on after it stopped responding.
job.cancelledA job ends as cancelled, whether you or WorkRoad cancelled it.
task.due_soonAn unfinished task is due within 24 hours. It is sent once per due date, so moving the due date sends it again.
webhook.testOnly 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:

{
   "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 specification, so any Standard Webhooks library can verify them.

HeaderValue
Content-Typeapplication/json
User-AgentWorkroad-Webhooks/1
X-Workroad-EventThe event type, such as job.succeeded.
webhook-idThe delivery id, the same as the body's id. Retries of a delivery keep it.
webhook-timestampWhen this attempt was signed, in Unix seconds. Each retry gets a new one.
webhook-signaturev1, 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:

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:

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.
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 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.