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

# Webhook events reference

> Every TimeTracker webhook event name, the request body and headers, how signatures are verified, and how retries and auto-disabling work.

TimeTracker posts workspace events to your own HTTPS endpoint. This page is the
contract: event names, the body, the headers and the delivery rules.

## The request

Every delivery is a `POST` with a JSON body.

```json theme={null}
{
  "version": 1,
  "event": {
    "key": "timesheet.approved",
    "occurredAt": 1753977600000,
    "workspaceId": "..."
  },
  "deliveryId": "...",
  "data": {
    "workspaceId": "...",
    "ownerUserId": "...",
    "submissionId": "...",
    "approverUserId": "...",
    "periodStart": 1753372800000,
    "periodEnd": 1753977600000
  }
}
```

| Field               | What it is                                                            |
| ------------------- | --------------------------------------------------------------------- |
| `version`           | The body format version. Currently `1`.                               |
| `event.key`         | The event name, from the table below.                                 |
| `event.occurredAt`  | When the event happened, in epoch milliseconds.                       |
| `event.workspaceId` | The workspace the event belongs to.                                   |
| `deliveryId`        | A stable id for this event and this endpoint. Use it to dedupe.       |
| `data`              | Stable ids and event-time values for the event. Fields vary by event. |

<Note>
  `data` carries **ids and immutable values**, not full records. Fetch the record if
  you need its current state – the payload is a fact about a moment, not a snapshot of
  your database.
</Note>

## Headers

| Header                      | Value                                      |
| --------------------------- | ------------------------------------------ |
| `content-type`              | `application/json`                         |
| `X-TimeTracker-Signature`   | `t=<unix seconds>,v1=<hex HMAC>`           |
| `X-TimeTracker-Delivery-Id` | The same value as `deliveryId` in the body |

## Verifying the signature

Each endpoint has its own signing secret, shown once when you create it and again
when you rotate it.

<Steps>
  <Step title="Read the header">
    Split `X-TimeTracker-Signature` on the comma. You get `t=<timestamp>` and
    `v1=<hex>`.
  </Step>

  <Step title="Build the signed string">
    Join the timestamp and the **raw request body** with a full stop:
    `<t>.<raw body>`. Use the bytes exactly as received – do not re-serialise the
    JSON, or the signature will not match.
  </Step>

  <Step title="Compute the HMAC">
    HMAC-SHA256 the signed string with your endpoint secret, hex-encoded.
  </Step>

  <Step title="Compare">
    Compare against the `v1` value using a constant-time comparison.
  </Step>
</Steps>

<Warning>
  **Allow a wide timestamp tolerance.** A retry reuses the original signature and its
  original timestamp, so a tolerance of a few seconds rejects every retry. Hours, not
  seconds.
</Warning>

## Deduplicate on `deliveryId`

Delivery is **at least once**. The same logical event can arrive twice – after a
network timeout, or when someone resends it by hand.

`deliveryId` is stable for a given event and endpoint. Store it and ignore a repeat.

## Event names

Names are `area.event`. Sixty-six events are published today.

### Timesheets

| Event                         | Fires when                             |
| ----------------------------- | -------------------------------------- |
| `timesheet.submitted`         | A person sends their week for review   |
| `timesheet.approved`          | An approver accepts a period           |
| `timesheet.changes_requested` | An approver sends a period back        |
| `timesheet.withdrawn`         | A submitter pulls their period back    |
| `timesheet.reopened`          | An approved period is reopened         |
| `timesheet.period_closed`     | A period is closed and locked          |
| `timesheet.reminder`          | A timesheet reminder goes out          |
| `timesheet.not_submitted`     | A period passes with nothing submitted |
| `timesheet.approval_overdue`  | A submitted period has waited too long |

### Time Clock

| Event                          | Fires when                             |
| ------------------------------ | -------------------------------------- |
| `timecard.submitted`           | A person submits a timecard period     |
| `timecard.approved`            | A timecard period is approved          |
| `timecard.changes_requested`   | A timecard period is sent back         |
| `timecard.correction_approved` | A shift-correction request is accepted |
| `timecard.correction_rejected` | A shift-correction request is declined |
| `timecard.missing_clock_out`   | A session was never clocked out        |

### Time off

| Event                         | Fires when                       |
| ----------------------------- | -------------------------------- |
| `time_off.requested`          | Someone asks for leave           |
| `time_off.approved`           | A request is approved            |
| `time_off.rejected`           | A request is declined            |
| `time_off.withdrawn`          | A requester pulls it back        |
| `time_off.approval_withdrawn` | An approver revokes an approval  |
| `time_off.approval_overdue`   | A request has waited too long    |
| `time_off.balance_adjusted`   | A balance is corrected by hand   |
| `time_off.upcoming`           | Approved leave is about to start |

### Expenses

| Event                             | Fires when                     |
| --------------------------------- | ------------------------------ |
| `expense.submitted`               | An expense is submitted        |
| `expense.approved`                | An expense is approved         |
| `expense.rejected`                | An expense is declined         |
| `expense.clarification_requested` | An approver asks a question    |
| `expense.approval_overdue`        | An expense has waited too long |

### Invoices and payments

| Event                   | Fires when                            |
| ----------------------- | ------------------------------------- |
| `invoice.created`       | An invoice is created                 |
| `invoice.sent`          | An invoice is sent to a client        |
| `invoice.paid`          | An invoice is settled in full         |
| `invoice.overdue`       | An invoice passes its due date unpaid |
| `invoice.voided`        | An invoice is voided                  |
| `payment.recorded`      | A payment is recorded                 |
| `payment.reminder_sent` | A payment reminder goes out           |

### Tasks

| Event                 | Fires when                       |
| --------------------- | -------------------------------- |
| `task.assigned`       | A task is assigned to someone    |
| `task.completed`      | A task is completed              |
| `task.reopened`       | A completed task is reopened     |
| `task.status_changed` | A task moves status              |
| `task.due_soon`       | A task's due date is approaching |

### Projects

| Event                              | Fires when                          |
| ---------------------------------- | ----------------------------------- |
| `project.status_changed`           | A project changes status            |
| `project.archived`                 | A project is archived               |
| `project.member_added`             | Someone joins a project             |
| `project.member_removed`           | Someone leaves a project            |
| `project.budget_threshold_reached` | A budget crosses an alert threshold |

### People and access

| Event                        | Fires when                            |
| ---------------------------- | ------------------------------------- |
| `member.invited`             | An invitation is sent                 |
| `member.invitation_accepted` | An invitation is accepted             |
| `member.invitation_revoked`  | An invitation is withdrawn            |
| `membership.role_changed`    | Someone's role changes                |
| `membership.removed`         | Someone is removed from the workspace |
| `workspace.member_deleted`   | A member's account is deleted         |

### Client portal

| Event                         | Fires when                      |
| ----------------------------- | ------------------------------- |
| `portal.comment_posted`       | A client contact comments       |
| `portal.deliverable_approved` | A client approves a deliverable |

### Reminders and comms

| Event                         | Fires when                        |
| ----------------------------- | --------------------------------- |
| `comment.mentioned`           | Someone is mentioned in a comment |
| `reminder.today_agenda`       | The morning agenda reminder       |
| `reminder.no_tasks_today`     | Nothing is planned for today      |
| `reminder.no_tasks_tomorrow`  | Nothing is planned for tomorrow   |
| `reminder.task_deadlines`     | The deadline rollup               |
| `notification.digest_summary` | A digest is compiled              |

### Billing, security and exports

| Event                             | Fires when                                  |
| --------------------------------- | ------------------------------------------- |
| `billing.subscription_activated`  | A subscription becomes active               |
| `billing.trial_ending`            | A trial is about to end                     |
| `billing.trial_expired`           | A trial has ended                           |
| `billing.payment_action_required` | A payment needs attention                   |
| `security.email_changed`          | An account email changes                    |
| `security.suspicious_login`       | An unusual sign-in is detected              |
| `export.ready`                    | An export finishes and is ready to download |

## Filtering what you receive

An endpoint with **no filters** receives every event. Otherwise you pick from these
categories, and an event matches if its own name or its category is in your list.

| Filter        | Covers                             |
| ------------- | ---------------------------------- |
| Assignments   | Task assignment events             |
| Due soon      | Task due-date warnings             |
| Overdue       | Overdue warnings                   |
| Mentions      | Comment mentions                   |
| Timesheets    | The timesheet workflow             |
| Budget alerts | Budget threshold events            |
| Scope changes | Estimate and scope approval events |
| Invoices      | The invoice and payment workflow   |
| Time off      | The leave workflow                 |
| Timecard      | The Time Clock workflow            |

## Endpoint rules

| Rule      | Detail                                                                                              |
| --------- | --------------------------------------------------------------------------------------------------- |
| Scheme    | HTTPS only                                                                                          |
| Address   | Must be publicly reachable. Private, loopback and cloud metadata addresses are refused.             |
| Redirects | Never followed. A `3xx` response is a failed delivery.                                              |
| Timing    | Events are dispatched about once a minute, so expect delivery within roughly a minute of the event. |

## Retries and failures

| What happens         | Detail                                                                                                |
| -------------------- | ----------------------------------------------------------------------------------------------------- |
| A temporary failure  | Retried with exponential backoff and jitter, starting at 30 seconds and capping at 1 hour             |
| Retry limit          | Up to 6 attempts, then the delivery is given up on                                                    |
| A permanent failure  | Given up on immediately – no retries for a rejected URL or a bad response class                       |
| Rate limiting        | A `Retry-After` response header is honoured, up to the 1-hour cap                                     |
| A long dead endpoint | After **20 consecutive** failed deliveries, the endpoint is switched off until an admin re-enables it |
| A success            | Resets the consecutive-failure count to zero                                                          |

Return a `2xx` quickly. Do your own work after you have acknowledged, not before.

### Delivery states you see in the app

| Badge         | Meaning                                           |
| ------------- | ------------------------------------------------- |
| **Delivered** | Your endpoint accepted it                         |
| **Retrying**  | A temporary failure, waiting for the next attempt |
| **Pending**   | Queued, not attempted yet                         |
| **Failed**    | Given up on                                       |

The endpoint itself shows **Active**, **Failing** or **Disabled**.

## When the app is off

Switching the Webhooks & API app off stops deliveries. Events raised while it is off
are **queued**, not dropped, and deliver when you switch it back on. Expect a burst
after a long pause.

## Permissions

| Action                              | Capability       | Roles        |
| ----------------------------------- | ---------------- | ------------ |
| Add, edit, delete, rotate or resend | `webhook.manage` | Owner, Admin |

Webhooks also need the Webhooks & API app switched on and a Pro plan.

## Common questions

<AccordionGroup>
  <Accordion title="Can I see my secret again later?">
    No. The secret is shown once, when you create the endpoint or rotate it. Store it
    somewhere safe. If you lose it, rotate to get a new one.
  </Accordion>

  <Accordion title="Why did my signature check fail?">
    Almost always because the body was re-serialised before signing. Sign the raw
    bytes exactly as received. The second cause is a timestamp tolerance that is too
    narrow for retries.
  </Accordion>

  <Accordion title="Why was my endpoint disabled?">
    Twenty consecutive failed deliveries. Fix the endpoint, then re-enable it in
    Settings → Webhooks.
  </Accordion>

  <Accordion title="Can I send events to localhost while testing?">
    No. Private and loopback addresses are refused. Use a public HTTPS tunnel.
  </Accordion>

  <Accordion title="How do I test an endpoint?">
    Send a test delivery from the endpoint's row in Settings → Webhooks. It uses the
    same signing and the same headers as a real event.
  </Accordion>
</AccordionGroup>

## Related guides

<CardGroup cols={2}>
  <Card title="Webhooks" icon="bolt" href="/integrations/webhooks">
    Setting one up, step by step.
  </Card>

  <Card title="Webhook settings" icon="gear" href="/settings/webhooks">
    The screen itself.
  </Card>

  <Card title="Notification types" icon="bell" href="/notifications/notification-types">
    The same events as in-app and email notices.
  </Card>

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