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
- Open the WhoWorked app.
- Go to Settings → Webhooks.
- Click Add endpoint, enter your URL, and select the events you want.
- Copy the signing secret. It starts with
whsec_and can be read again from the endpoint page. - 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
3xxresponse 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:
| Event | Sent when |
|---|---|
entry.created | A time entry is created. |
entry.updated | A time entry is updated. |
entry.deleted | A time entry is deleted. |
project.created | A project is created. |
project.updated | A project is updated. |
project.deleted | A project is deleted. |
client.created | A client is created. |
client.updated | A client is updated. |
client.deleted | A client is deleted. |
member.created | A workspace member is added. |
member.updated | A workspace member is updated. |
member.deleted | A 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"
}
}| Field | Notes |
|---|---|
id | Public whd_ delivery ID. Use it to detect duplicates. |
event | Event type from the table above. |
created_at | When the event occurred, as an ISO 8601 timestamp. Not when it arrived. |
actor | Who caused the event. type is user, oauth_app, api_key, or system. id can be null. |
changed_fields | Update events only. Field names come from the audit log, so they can be camelCase even though data keys are snake_case. |
data | Entity 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:
| Header | Value |
|---|---|
whoworked-signature | t=<unix-seconds>,v1=<hex-hmac-sha256> |
whoworked-event | Event type. Convenience only, not authenticated. |
whoworked-delivery-id | Public whd_ delivery ID. Convenience only, not authenticated. |
content-type | application/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:
| Retry | Delay |
|---|---|
| 1 | 60 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 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.
| Method | Path | Purpose |
|---|---|---|
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}/secret | Reveal the signing secret. |
POST | /{endpoint_id}/rotate-secret | Issue a new signing secret. |
GET | /{endpoint_id}/deliveries | List deliveries, newest first. |
GET | /{endpoint_id}/deliveries/{delivery_id} | Get one delivery with request and response bodies. |
POST | /{endpoint_id}/deliveries/{delivery_id}/retry | Requeue a failed or dead delivery. Other states return 409. |
POST | /{endpoint_id}/test | Send 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
2xxfast and queue the work. The attempt times out after 10 seconds. - Deduplicate on the payload
id. Delivery is at-least-once, so the samewhd_ID can arrive twice. - Tolerate out-of-order arrival. Use
created_atand 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.