WhoWorked

Webhooks

Receive signed HTTP callbacks when time entries, projects, clients, and members change in your workspace.

Webhooks push workspace events to your server as they happen, so you don't have to poll the API. WhoWorked sends an HTTP POST with a JSON body and an HMAC signature to every endpoint subscribed to the event.

Webhooks are available on paid plans. On plans without them, the webhooks settings page shows an upgrade prompt instead of the endpoint list.

Create an endpoint

  1. Open the WhoWorked app.
  2. Go to SettingsWebhooks.
  3. Click Add endpoint, enter your URL, and select the events you want.
  4. Copy the signing secret. It starts with whsec_ and can be read again from the endpoint page.
  5. Click Send test event to verify your receiver before you go live.

Endpoints have a public whe_ ID. Every delivery has a public whd_ ID. Only workspace owners and admins can manage webhooks.

Endpoint requirements

  • The URL must use HTTPS and must not embed credentials such as user:pass@host.
  • The hostname must resolve to public IP addresses only. Private, loopback, link-local, CGNAT, reserved, and metadata addresses are rejected for IPv4 and IPv6, including IPv4-mapped IPv6 literals.
  • Redirects are not followed. A 3xx response counts as a failed attempt, so register the final URL.

In local development the API also accepts plain HTTP for localhost, 127.0.0.1, and [::1].

Events

Subscribe an endpoint to any of these events:

EventSent when
entry.createdA time entry is created.
entry.updatedA time entry is updated.
entry.deletedA time entry is deleted.
project.createdA project is created.
project.updatedA project is updated.
project.deletedA project is deleted.
client.createdA client is created.
client.updatedA client is updated.
client.deletedA client is deleted.
member.createdA workspace member is added.
member.updatedA workspace member is updated.
member.deletedA workspace member is removed.

webhook.test is sent by the test action only. It is not subscribable, and a failed test send is not retried.

Payload

{
  "id": "whd_eeeeeeee-0000-0000-0000-000000000005",
  "event": "entry.updated",
  "created_at": "2026-07-28T10:15:00.000Z",
  "actor": {
    "type": "user",
    "id": "user_01ABC"
  },
  "changed_fields": ["description"],
  "data": {
    "id": "ent_cccccccc-0000-0000-0000-000000000003",
    "workspace_id": "wsp_bbbbbbbb-0000-0000-0000-000000000002",
    "project_id": "prj_dddddddd-0000-0000-0000-000000000004",
    "task_id": null,
    "user_id": "user_01ABC",
    "description": "new",
    "billable": true,
    "start": "2026-07-28T09:00:00.000Z",
    "end": "2026-07-28T10:00:00.000Z"
  }
}
FieldNotes
idPublic whd_ delivery ID. Use it to detect duplicates.
eventEvent type from the table above.
created_atWhen the event occurred, as an ISO 8601 timestamp. Not when it arrived.
actorWho caused the event. type is user, oauth_app, api_key, or system. id can be null.
changed_fieldsUpdate events only. Field names come from the audit log, so they can be camelCase even though data keys are snake_case.
dataEntity snapshot after a create or update, before a delete. Can be null when no snapshot exists. Top-level keys are snake_case.

Resource IDs inside data carry their public prefixes: ent_, prj_, cli_, wsp_, tsk_, agt_, and ses_. Member IDs and user_id values are WorkOS IDs and are already public.

The test payload is smaller:

{
  "id": "whd_eeeeeeee-0000-0000-0000-000000000006",
  "event": "webhook.test",
  "created_at": "2026-07-28T10:20:00.000Z",
  "data": {
    "message": "Test event from WhoWorked"
  }
}

Verify the signature

Every delivery carries these headers:

HeaderValue
whoworked-signaturet=<unix-seconds>,v1=<hex-hmac-sha256>
whoworked-eventEvent type. Convenience only, not authenticated.
whoworked-delivery-idPublic whd_ delivery ID. Convenience only, not authenticated.
content-typeapplication/json

The signature is an HMAC-SHA256 of ${t}.${rawBody} keyed with the endpoint secret. Capture the raw body before parsing JSON, reject timestamps outside a tolerance window (300 seconds is a good default), and compare in constant time.

With the SDK

The whoworked package does all of that for you. constructWebhookEvent verifies the signature and timestamp, then returns the parsed payload, so anything it returns is authenticated:

import { constructWebhookEvent, WebhookVerificationError } from "whoworked";

export async function POST(request: Request) {
  try {
    const event = await constructWebhookEvent({
      payload: await request.text(),
      signature: request.headers.get("whoworked-signature") ?? "",
      secret: process.env.WHOWORKED_WEBHOOK_SECRET!,
    });

    if (event.event === "entry.created") {
      await queue.push(event.id, event.data);
    }
    return new Response("ok");
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      // invalid_signature_header | timestamp_out_of_tolerance |
      // signature_mismatch | invalid_payload
      return new Response(error.code, { status: 400 });
    }
    throw error;
  }
}

Pass toleranceSeconds to change the replay window from its 300 second default. payload accepts a string, Uint8Array, or ArrayBuffer, so raw bytes work unchanged. Use verifyWebhookSignature when you only want a boolean. Both are built on Web Crypto and run on Node 18+, Bun, Deno, Cloudflare Workers, and Vercel edge functions.

Without the SDK

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

export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) {
    return false;
  }
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && timingSafeEqual(Buffer.from(expected, "hex"), given);
}

The signed body is authoritative. Read event and id from the verified body, never from the convenience headers.

Rotating a secret from the endpoint page issues a new whsec_ value immediately. Deliveries already in flight are signed with the previous secret, so accept both for a short window when you rotate.

Retries and failures

A delivery succeeds on any 2xx response. Timeouts, network errors, and non-2xx responses fail the attempt. Each attempt has a 10 second response deadline.

WhoWorked makes up to 5 attempts per delivery, backing off between them:

RetryDelay
160 seconds
25 minutes
330 minutes
42 hours

After the fifth failed attempt the delivery is dead and is not retried automatically. A successful delivery resets the endpoint's consecutive failure counter. After 10 consecutive failed deliveries the endpoint is auto-disabled: its pending deliveries stop, the workspace owner gets an email, and re-enabling it from the settings page resets the counter.

Delivery log

Each endpoint page shows its deliveries with status, attempt count, HTTP status, the exact request body sent, and a response excerpt capped at 4 KB. Statuses are pending, processing, sent, failed, and dead.

Use Retry on a failed or dead delivery to reset its attempt counter and send it again right away. Logs are retained for 30 days.

Managing endpoints over the API

The webhook management endpoints live under /v1/workspaces/{workspace_id}/webhooks and require an owner or admin session. API keys and OAuth tokens receive 403, so use the app to manage endpoints.

MethodPathPurpose
GET/List endpoints. Never includes the secret.
POST/Create an endpoint. Returns the signing secret.
GET/{endpoint_id}Get one endpoint.
PATCH/{endpoint_id}Update url, description, event types, or status.
DELETE/{endpoint_id}Delete the endpoint and stop pending deliveries.
GET/{endpoint_id}/secretReveal the signing secret.
POST/{endpoint_id}/rotate-secretIssue a new signing secret.
GET/{endpoint_id}/deliveriesList deliveries, newest first.
GET/{endpoint_id}/deliveries/{delivery_id}Get one delivery with request and response bodies.
POST/{endpoint_id}/deliveries/{delivery_id}/retryRequeue a failed or dead delivery. Other states return 409.
POST/{endpoint_id}/testSend a signed webhook.test event and wait for the response.

The delivery list takes status, event_type, limit (max 100), and either cursor or since. Passing since returns only rows created after that instant, oldest first, which is how the delivery log polls for new activity. since and cursor page in opposite directions and cannot be combined.

See the REST API reference for full request and response schemas.

Best practices

  • Verify the signature against the unmodified raw body before parsing or processing anything.
  • Enforce a timestamp tolerance and compare signatures in constant time.
  • Return 2xx fast and queue the work. The attempt times out after 10 seconds.
  • Deduplicate on the payload id. Delivery is at-least-once, so the same whd_ ID can arrive twice.
  • Tolerate out-of-order arrival. Use created_at and your current state instead of assuming order.
  • Keep signing secrets out of logs, and rotate immediately if one leaks.
  • Ignore unknown fields so additive payload changes don't break your receiver.

On this page