> ## Documentation Index
> Fetch the complete documentation index at: https://docs.timetracker.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Send TimeTracker events to your own systems over HTTPS. Create an endpoint, verify the signature, read the payload, handle retries and rotate the secret.

A webhook sends workspace events to a URL you own, so your own systems can react
when something happens in TimeTracker.

Go to **Settings → Webhooks**.

<Info>
  Webhooks is a **Pro** feature and lives in the **Webhooks & API** app, which is on
  by default. See [Plans and features](/concepts/plans-and-features).
</Info>

## What a webhook is

An HTTPS endpoint you register. When a matching event happens, TimeTracker sends
it a signed `POST` with a JSON body describing what occurred.

The page describes it as: *"Send workspace events to your own systems over HTTPS.
Every delivery is signed with a per-endpoint secret you can verify from the
`X-TimeTracker-Signature` header."*

## When to use one

* Post approved timesheets into your payroll system.
* Tell your accounting tool when an invoice is paid.
* Push new tasks into a build pipeline or a support tool.
* Feed workspace activity into your own dashboard.

## Create an endpoint

<Steps>
  <Step title="Open Settings → Webhooks">
    You need to be an Owner or an Admin.
  </Step>

  <Step title="Click Add endpoint">
    The button sits in the page header. The empty state also offers it.
  </Step>

  <Step title="Enter the endpoint URL">
    **Endpoint URL** – required, and must be a public `https://` address, for
    example `https://api.example.com/webhooks/timetracker`.
  </Step>

  <Step title="Choose event filters">
    **Event filters** – leave it empty to receive **every** event. This is the
    reliable choice for most teams.
  </Step>

  <Step title="Save">
    Click **Add endpoint**.
  </Step>

  <Step title="Copy the signing secret">
    A dialog appears titled **Copy your signing secret**. Copy it now – it is
    shown once and can never be read again.
  </Step>
</Steps>

<Warning>
  **The signing secret is shown exactly once.** There is no screen anywhere that can
  show it again. If you lose it, rotate it and update your receiver.
</Warning>

### Fields

| Field             | Type                   | Notes                                                                                           |
| ----------------- | ---------------------- | ----------------------------------------------------------------------------------------------- |
| **Endpoint URL**  | URL, required          | Public HTTPS only                                                                               |
| **Event filters** | Multi-select, optional | Empty means all events. Helper text: *"No selection means this endpoint receives every event."* |
| **Active**        | Switch                 | *"When off, no events are delivered to this endpoint."* Available when editing.                 |

<Tip>
  The most robust setup is to leave the filter empty and route on `event.key`
  inside your own receiver. Your code then owns the routing, and adding a new event
  type never needs a settings change.
</Tip>

### URL rules

Only **public HTTPS** addresses are accepted. TimeTracker rejects:

| Rejected                      | Example                                       |
| ----------------------------- | --------------------------------------------- |
| Anything not `https://`       | `http://example.com/hook`                     |
| Loopback names                | `localhost`, `api.localhost`                  |
| Numeric IP literals           | `2130706433`, `0x7f000001`                    |
| Private and link-local ranges | `10.0.0.5`, `192.168.1.10`, `169.254.169.254` |
| Cloud metadata addresses      | `169.254.169.254`                             |

The error reads: *"This URL can't be used. Endpoints must be public HTTPS
addresses – private, loopback, and metadata addresses are blocked."*

A hostname that passes here is **re-checked when the request is actually sent**,
so a domain that later resolves to a private address is still blocked. Redirects
are never followed – your endpoint must answer with a `2xx` directly.

## The request

| Property           | Value              |
| ------------------ | ------------------ |
| Method             | `POST`             |
| Content type       | `application/json` |
| Redirects          | Not followed       |
| Delivery guarantee | At least once      |

### Headers

| Header                      | Value                                        |
| --------------------------- | -------------------------------------------- |
| `X-TimeTracker-Signature`   | `t=<unixSeconds>,v1=<hexHmac>`               |
| `X-TimeTracker-Delivery-Id` | A stable id for this event and endpoint pair |

`X-TimeTracker-Delivery-Id` is stable across retries of the same delivery.
**Deduplicate on it.** Delivery is at least once, so the same id can arrive
twice.

### Body

```json theme={null}
{
 "version": 1,
 "event": {
 "key": "task.assigned",
 "occurredAt": 1753963200000,
 "workspaceId": "j57abc..."
 },
 "deliveryId": "kn7xyz...:jd91abc...",
 "data": {
 "workspaceId": "j57abc...",
 "taskId": "jd12...",
 "projectId": "jp34...",
 "assigneeId": "ju56...",
 "title": "Homepage wireframes",
 "assignedAt": 1753963200000,
 "assignerName": "Priya Raman",
 "projectName": "Website Redesign",
 "taskKey": "BLU-142",
 "dueDate": 1754049600000
 }
}
```

| Field               | Type   | Meaning                                  |
| ------------------- | ------ | ---------------------------------------- |
| `version`           | number | Envelope version. Currently `1`.         |
| `event.key`         | string | The event type, e.g. `invoice.paid`      |
| `event.occurredAt`  | number | Epoch milliseconds                       |
| `event.workspaceId` | string | The workspace the event belongs to       |
| `deliveryId`        | string | Matches the delivery header              |
| `data`              | object | The event payload, shaped per event type |

`data` holds the values as they were **at the moment the event happened**. It is a
snapshot, not a live record, so a later rename does not change an old delivery.

## Verifying the signature

The signature header looks like:

```
X-TimeTracker-Signature: t=1753963200,v1=5d41402abc4b2a76b9719d911017c592...
```

The algorithm is **HMAC-SHA-256**, hex-encoded, over the exact string:

```
${t}.${rawBody}
```

`t` is unix **seconds**. `rawBody` is the byte-identical JSON that arrived.

<Warning>
  Sign the **raw request body**, exactly as received. Parsing the JSON and
  re-serialising it changes the bytes and the signature will never match.
</Warning>

Your receiver should:

<Steps>
  <Step title="Read the raw body as text">
    Before any JSON parsing.
  </Step>

  <Step title="Split the header">
    Split on `,` to get `t=` and `v1=`.
  </Step>

  <Step title="Check the timestamp">
    Reject if `t` is more than **300 seconds** (5 minutes) away from now. This
    stops replay attacks.
  </Step>

  <Step title="Recompute the HMAC">
    HMAC-SHA-256 of `t + "." + rawBody` with your signing secret, hex-encoded.
  </Step>

  <Step title="Compare in constant time">
    Never use a plain string equality check.
  </Step>
</Steps>

### Example verifier

```js theme={null}
import crypto from "node:crypto";

function verify(rawBody, header, secret) {
 const parts = Object.fromEntries(
 header.split(",").map((p) => p.split("=", 2)),
 );
 const t = Number(parts.t);
 if (!Number.isFinite(t)) return false;

 // 5-minute replay window
 if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;

 const expected = crypto
 .createHmac("sha256", secret)
 .update(`${t}.${rawBody}`)
 .digest("hex");

 const a = Buffer.from(expected, "hex");
 const b = Buffer.from(parts.v1 ?? "", "hex");
 return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Answer with any `2xx` once you have accepted the event. Do the slow work
afterwards.

## Delivery and retries

Events are dispatched about once a minute, so a webhook normally lands within a
minute of the action that caused it.

| Response      | Result                                          |
| ------------- | ----------------------------------------------- |
| `2xx`         | Success                                         |
| `3xx`         | Permanent failure – redirects are not followed  |
| `429`         | Retried, honouring `Retry-After`                |
| `5xx`         | Retried                                         |
| Other `4xx`   | Permanent failure – not retried                 |
| Network error | Retried                                         |
| DNS failure   | Retried, and does not count toward auto-disable |

Retries use exponential backoff with jitter: roughly 30 seconds, 1 minute, 2
minutes, 4 minutes, 8 minutes, up to a ceiling of **1 hour** between attempts.
There are at most **6 attempts**.

### Auto-disable

An endpoint that fails **20 times in a row** is switched off automatically.

The endpoint card then shows a red alert: **"Endpoint disabled after repeated
failures"** – *"We stopped sending after N consecutive failures. Fix your
receiver, then re-enable this endpoint."*

Click **Re-enable** and confirm. The dialog reads *"Deliveries resume immediately
and the failure count resets. Make sure your receiver is fixed first."*

**Any success resets the counter to zero**, so an endpoint that is merely flaky
never gets disabled.

## The endpoint card

| Part             | What it shows                                                               |
| ---------------- | --------------------------------------------------------------------------- |
| **Title**        | The URL, in monospace. The full URL appears on hover.                       |
| **Status badge** | **Active**, **Failing** (at least one consecutive failure), or **Disabled** |
| **Filter chips** | Your chosen filters, or a single **All events** chip                        |
| **Failure note** | `N consecutive failures`, when there are any                                |
| **Added**        | The date you created it                                                     |

### Row actions

The `⋯` menu on each endpoint holds:

| Action              | What it does                                      |
| ------------------- | ------------------------------------------------- |
| **Edit**            | Change the URL, filters, or the **Active** switch |
| **Send test event** | Fires a `webhook.test` delivery immediately       |
| **Rotate secret**   | Issues a new signing secret                       |
| **Delete**          | Removes the endpoint and its history              |

## Sending a test event

Pick **Send test event** from the `⋯` menu. You get a toast – **"Test event
sent."** or `Test event failed: <reason>.`

The test body is:

```json theme={null}
{
 "version": 1,
 "event": { "key": "webhook.test", "occurredAt": 1753963200000, "workspaceId": "..." },
 "deliveryId": "attempt:...",
 "data": { "test": true }
}
```

A test never touches your real event stream. It is safe to send at any time.

## The delivery log

Each endpoint has a **Delivery log** button that expands a table. It holds the
most recent **100** deliveries, newest first.

| Column        | What it shows                                                                           |
| ------------- | --------------------------------------------------------------------------------------- |
| **Event**     | The event's id, or `webhook.test` for a test send                                       |
| **Status**    | **Delivered**, **Failed**, **Retrying** or **Pending**, with the HTTP status when known |
| **Attempt**   | Which attempt this row is                                                               |
| **Time**      | When the attempt ran                                                                    |
| **Redeliver** | A button to send it again                                                               |

Empty state: *"No deliveries yet."*

### Redelivering

Click **Redeliver** on any row. You get **"Delivery re-queued."**

Redelivery adds a **new** attempt and never rewrites the old row, so your history
stays honest. The new attempt is signed with your **current** secret.

## Rotating the secret

<Steps>
  <Step title="Open the endpoint menu">
    Click `⋯`, then **Rotate secret**.
  </Step>

  <Step title="Confirm">
    The dialog reads *"Rotating invalidates the current secret immediately.
    In-flight deliveries keep their existing signature; new deliveries use the
    new secret."*
  </Step>

  <Step title="Copy the new secret">
    A dialog appears titled **New signing secret**: *"The previous secret is now
    invalid. Update your receiver with this value. It won't be shown again."*
  </Step>

  <Step title="Update your receiver">
    Deploy the new secret before your next event arrives.
  </Step>
</Steps>

Deliveries already queued keep the secret they were signed with, so a rotation
never breaks a retry that is already in flight.

<Tip>
  Accept both the old and the new secret for a few minutes while you deploy. Try
  the new one first, then fall back to the old one.
</Tip>

## Deleting an endpoint

Pick **Delete** from the `⋯` menu.

The confirmation is titled **"Delete this endpoint?"** – *"This permanently
deletes the endpoint and its delivery history. This cannot be undone."*

You must **type the endpoint URL** to confirm. Then click **Delete endpoint**.

<Warning>
  Deleting removes the delivery history too. Export anything you need first.
</Warning>

## Event types

There are **66 event keys**, and almost all of them can be delivered by webhook.
Full detail – trigger, recipients and channels – is in the
[notification types reference](/notifications/notification-types).

**Timesheets**

`timesheet.submitted` · `timesheet.approved` · `timesheet.changes_requested` ·
`timesheet.reopened` · `timesheet.withdrawn` · `timesheet.reminder` ·
`timesheet.approval_overdue` · `timesheet.not_submitted` · `timesheet.period_closed`

**Timecards**

`timecard.submitted` · `timecard.approved` · `timecard.changes_requested` ·
`timecard.correction_approved` · `timecard.correction_rejected` ·
`timecard.missing_clock_out`

**Time off**

`time_off.requested` · `time_off.approved` · `time_off.rejected` ·
`time_off.withdrawn` · `time_off.approval_withdrawn` ·
`time_off.balance_adjusted` · `time_off.approval_overdue` · `time_off.upcoming`

**Expenses**

`expense.submitted` · `expense.approved` · `expense.rejected` ·
`expense.clarification_requested` · `expense.approval_overdue`

**Tasks**

`task.assigned` · `task.completed` · `task.reopened` · `task.status_changed` ·
`task.due_soon`

**Projects**

`project.member_added` · `project.member_removed` · `project.archived` ·
`project.status_changed` · `project.budget_threshold_reached`

**Invoices and payments**

`invoice.created` · `invoice.sent` · `invoice.paid` · `invoice.voided` ·
`invoice.overdue` · `payment.recorded` · `payment.reminder_sent`

**Comments and portal**

`comment.mentioned` · `portal.comment_posted` · `portal.deliverable_approved`

**Members and access**

`member.invited` · `member.invitation_accepted` · `member.invitation_revoked` ·
`membership.role_changed` · `membership.removed` · `workspace.member_deleted`

**Security**

`security.email_changed` · `security.suspicious_login`

**Billing**

`billing.trial_ending` · `billing.trial_expired` ·
`billing.subscription_activated` · `billing.payment_action_required`

**Exports**

`export.ready`

**Testing**

`webhook.test` – sent only by **Send test event**, never by real activity.

<Note>
  Digest summaries are not delivered by webhook.
</Note>

## Example

Daniel Okafor wants Northwind Studio's accounting system to record a payment the
moment an invoice is marked paid.

<Steps>
  <Step title="Add the endpoint">
    URL `https://api.northwind.example/hooks/timetracker`. He leaves the filter
    empty.
  </Step>

  <Step title="Store the secret">
    He copies the signing secret into his server's environment variables.
  </Step>

  <Step title="Write the receiver">
    It verifies the signature, checks `event.key === "invoice.paid"`, and ignores
    everything else.
  </Step>

  <Step title="Test it">
    **Send test event**. His endpoint logs a `webhook.test` body and answers 200.
  </Step>

  <Step title="Watch the log">
    Bluebird Coffee pays their \$40,000 invoice. Within a minute the delivery log
    shows **Delivered · 200**.
  </Step>
</Steps>

## Permissions

| Action                  | Capability       | Roles        |
| ----------------------- | ---------------- | ------------ |
| See and manage webhooks | `webhook.manage` | Owner, Admin |

Project Manager, Finance, Member, Contractor and Client do **not** hold it. A
webhook can carry every workspace event to an external system, so it is
deliberately restricted to the two roles that already administer the workspace.

Every action is re-checked on the server. Endpoints from another workspace are
never reachable.

## What happens when the app is off

Turning the **Webhooks & API** app off under **Settings → Apps** stops delivery.
Events that happen while it is off are **queued and delivered when you turn it
back on**. Your configuration and delivery history are kept.

## Common questions

<AccordionGroup>
  <Accordion title="Can I see the signing secret again?">
    No. It is shown once, at creation and after a rotation. If you lose it,
    rotate and update your receiver.
  </Accordion>

  <Accordion title="How fast is a webhook?">
    Normally within a minute of the event. Deliveries are dispatched on a short
    cycle rather than instantly.
  </Accordion>

  <Accordion title="Can the same event arrive twice?">
    Yes. Delivery is at least once. Deduplicate on `X-TimeTracker-Delivery-Id`,
    which stays the same across retries of the same delivery.
  </Accordion>

  <Accordion title="Can I use an http:// URL for local testing?">
    No. Only public HTTPS addresses are accepted, and loopback and private
    addresses are blocked. Use a public HTTPS tunnel while developing.
  </Accordion>

  <Accordion title="What should my endpoint return?">
    Any `2xx`, as soon as you have accepted the event. Do the slow work after
    responding. A `3xx` counts as a failure – redirects are never followed.
  </Accordion>

  <Accordion title="My endpoint went to Disabled. What now?">
    Fix your receiver, then click **Re-enable** on the endpoint card. The failure
    count resets and deliveries resume immediately.
  </Accordion>

  <Accordion title="Do webhooks respect a person's notification settings?">
    No. Webhooks are a workspace-level integration, not a personal channel. They
    are configured per endpoint here, not on anyone's profile.
  </Accordion>
</AccordionGroup>

## Troubleshooting

**Every delivery fails with a signature mismatch.** You are almost certainly
re-serialising the body before verifying. Sign the raw bytes exactly as they
arrived.

**Deliveries stop after a while.** Check the status badge. Twenty consecutive
failures disables an endpoint. Fix the receiver and re-enable it.

**The URL will not save.** It must be public HTTPS. Private, loopback and
metadata addresses are blocked, including numeric forms of them.

**I get nothing at all.** Confirm the endpoint is **Active**, the Webhooks & API
app is on, and your plan includes it. Then use **Send test event** to prove the
path end to end.

**Deliveries arrive but my filter drops them.** Clear the filter so the endpoint
receives everything, and route on `event.key` in your own code.

## Related guides

<CardGroup cols={2}>
  <Card title="Integrations overview" icon="plug" href="/integrations/overview">
    Everything TimeTracker connects to.
  </Card>

  <Card title="Notification types" icon="list" href="/notifications/notification-types">
    What each event means and when it fires.
  </Card>

  <Card title="Webhook events reference" icon="code" href="/reference/webhook-events">
    The event catalogue at a glance.
  </Card>

  <Card title="Roles and capabilities" icon="shield" href="/concepts/roles-and-capabilities">
    Who holds `webhook.manage`.
  </Card>

  <Card title="Plans and features" icon="tag" href="/concepts/plans-and-features">
    Webhooks is a Pro feature.
  </Card>

  <Card title="Export your data" icon="download" href="/integrations/import-your-data">
    Getting data in and out.
  </Card>
</CardGroup>
