# Guide to Toggl Webhooks: Features and Best Practices

Toggl Track is a time tracking tool used by freelancers and teams to log hours against clients, projects and tasks. Webhooks let other systems react as that data changes: a new time entry for invoicing, a project created in Toggl that needs to exist in your project management tool, or a timesheet change that payroll should know about.

This guide covers the Toggl Track Webhooks API: how subscriptions and event filters work, the validation step every subscription needs, how to verify the `X-Webhook-Signature-256` header, and the best practices for running a Toggl receiver in production.

## What are Toggl webhooks?

A Toggl webhook is a **subscription** in one workspace. It has a description, a secret, one or more **event filters**, a callback URL, and two switches: enabled and validated. When an entity that matches a filter is created, updated or deleted, Toggl sends a `POST` with a JSON body to the callback URL.

Toggl only sends events once the subscription is both enabled and validated. Validation proves that the person who created the subscription controls the URL, so Toggl does not send unsolicited traffic to someone else's endpoint.

Every request carries an `X-Webhook-Signature-256` header: `sha256=` followed by the hex HMAC-SHA256 of the full body, keyed with the subscription's secret.

## Toggl webhook features

| Feature | Details |
| --- | --- |
| Configuration | Webhooks API, `https://api.track.toggl.com/webhooks/api/v1` |
| Subscription model | Per workspace and creator; event filters of `{entity, action}` |
| Permissions | Events are filtered by what the creator can see |
| Validation | Required before events flow: echo `validation_code` or call the validate endpoint |
| Signature header | `X-Webhook-Signature-256` |
| Scheme | `sha256=` followed by lowercase hex HMAC-SHA256 |
| Signed content | The full unparsed body |
| Secret | Set when creating the subscription, or generated by Toggl |
| Replay protection | Body `timestamp` (refreshed on each attempt) and `url_callback` |
| Retries | "Some time later"; schedule, count and timeout not published |
| Test event | `PING`, sendable on demand |
| Limits | Free 1 webhook (3 events), Starter 2 (6), Premium 3 (12), Enterprise custom; per workspace |
| Source IPs | Not published |

## Common events

Event filters pair an entity with an action. `*` matches any entity or any action.

| Entity | Actions |
| --- | --- |
| `time_entry` | `created`, `updated`, `deleted` |
| `project` | `created`, `updated`, `deleted` |
| `client` | `created`, `updated`, `deleted` |
| `task` | `created`, `updated`, `deleted` |
| `tag` | `created`, `updated`, `deleted` |
| `project_group` | `created`, `updated`, `deleted` |
| `project_user` | `created`, `updated`, `deleted` |
| `workspace_user` | `created`, `updated`, `deleted` |
| `timesheet` | `created`, `updated`, `deleted` |
| `workspace` | `updated` |

`GET /event_filters` returns the current list. Some example filter sets from Toggl's docs:

```text
{ "event_filters": [{ "entity": "project", "action": "created" }] }
{ "event_filters": [{ "entity": "time_entry", "action": "updated" }, { "entity": "client", "action": "updated" }] }
{ "event_filters": [{ "entity": "*", "action": "*" }] }

```

The plan limits count events per webhook, so a wildcard filter on a Free plan is not always possible. Pick the specific pairs you need.

Toggl documents the body of only one event: the `PING`. As sent, it looks like this:

```json
{"event_id":0,"created_at":"2022-06-25T03:58:10.207820267Z","creator_id":6,"metadata":{"request_type":"POST","event_user_id":6},"payload":"ping","subscription_id":6,"timestamp":"2022-06-25T03:58:10.207820267Z","url_callback":"https://callback-url.com"}

```

`payload` is the string `"ping"` here. For entity events, Toggl does not publish the body shape, so capture a few real deliveries before writing parsers, and route on what you observe rather than on assumed field names.

## Setting up Toggl webhooks

Create a subscription with your Toggl API token as the basic auth username and the literal string `api_token` as the password:

```bash
curl -X POST "https://api.track.toggl.com/webhooks/api/v1/subscriptions/$WORKSPACE_ID" \
  -u "$TOGGL_API_TOKEN:api_token" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Time entries to invoicing",
    "url_callback": "https://example.com/webhooks/toggl",
    "event_filters": [{ "entity": "time_entry", "action": "*" }],
    "enabled": true,
    "secret": "'"$TOGGL_WEBHOOK_SECRET"'"
  }'

```

The `description` must be unique. If you omit `secret`, Toggl generates one and returns it in the response; store it server-side either way. The subscription now exists but is not validated, so no entity events will arrive yet.

### Validating the subscription

When a subscription is not yet validated, Toggl's `PING` event includes a `validation_code` field. There are two ways to complete validation:

1. **Synchronous:** your endpoint responds to the `PING` with the code in the body: `{"validation_code": "<validation_code>"}`.
2. **Asynchronous:** you make a `GET` request to `https://api.track.toggl.com/webhooks/api/v1/validate/{workspace_id}/{subscription_id}/{validation_code}`. Toggl returns `OK` and marks the subscription validated. The endpoint returns `400` if the code is not found or the subscription is already validated.

To trigger a `PING` at any time, call `POST /ping/{workspace_id}/{subscription_id}`.

Hookdeck does not echo Toggl's validation code. If your callback URL is a Hookdeck source, use the asynchronous route: send a `PING`, open the event in Hookdeck to read `validation_code` from the body, and call the validate URL.

## Securing Toggl webhooks

Compute an HMAC-SHA256 of the full raw body with the subscription's secret, encode it as lowercase hex, prefix `sha256=` and compare it with the header in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTogglSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader || !secret) return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/toggl', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.TOGGL_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyTogglSignature(req.body, req.get('x-webhook-signature-256'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));

  // Reject events meant for another subscription's URL.
  if (event.url_callback !== process.env.TOGGL_CALLBACK_URL) return res.sendStatus(401);

  // Optional: Toggl suggests ignoring old timestamps, with about a minute of margin.
  // const ageMs = Date.now() - Date.parse(event.timestamp);
  // if (ageMs > 60_000) return res.sendStatus(400);

  if (event.validation_code) {
    return res.json({ validation_code: event.validation_code }); // synchronous validation
  }

  // Queue the event for processing.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_toggl_signature(raw_body: bytes, signature_header, secret: str) -> bool:
    if not signature_header or not secret:
        return False
    expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_toggl_signature(raw_body, request.headers.get("x-webhook-signature-256"),
#                        os.environ["TOGGL_WEBHOOK_SECRET"])

```

Toggl's docs include a test vector. With the secret `PGuRrhCFajIyEvFlreKL`, the raw `PING` body shown earlier gives `sha256=bf829606cda0ca6923defb5ca70a43135adc7e8887486a201a19cb50ca6006b1`. The docs also show the same event pretty-printed; the signature is over the compact form actually sent.

Two body fields add protection beyond the signature. `timestamp` is the time Toggl sent this attempt, refreshed on every retry, so old values suggest a replay. `url_callback` is the subscription's URL; comparing it with your own stops an event for one subscription being forwarded to another that shares the same secret. Using a different secret per subscription prevents that as well.

> **Make Toggl webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Webhook-Signature-256`, records every Toggl delivery including the validation `PING`, and queues events until your service has processed them.

## Toggl webhook limitations and pain points

### Validation before any events

**The Problem:** A new subscription is created, but no events arrive.

**Why It Happens:** Toggl only delivers to subscriptions that are enabled and validated. If the endpoint did not echo `validation_code` and nobody called the validate endpoint, the subscription stays unvalidated.

**Workarounds:**

* Echo the code from your handler, as in the example above.
* Or send a `PING`, read the code from your logs and call the validate URL.

**How Hookdeck Can Help:** Hookdeck shows the `PING` body in its event log, so the `validation_code` is easy to copy for the asynchronous validate call.

### Undocumented entity payloads

**The Problem:** Parsers break or miss fields on real events.

**Why It Happens:** Toggl documents the `PING` body but not the bodies of entity events.

**Workarounds:**

* Capture real deliveries for each entity and action you subscribe to before writing parsers.
* Read fields defensively and log anything unexpected.

**How Hookdeck Can Help:** Hookdeck stores each request with headers and body, so you can inspect real Toggl payloads and replay them against your handler while you build it.

### Plan limits on webhooks and events

**The Problem:** You cannot create the subscriptions you need.

**Why It Happens:** Each workspace is limited to 1, 2 or 3 webhooks on Free, Starter and Premium, with 3, 6 or 12 events per webhook.

**Workarounds:**

* Use one subscription with all the filters you need, and route by content in your service.

**How Hookdeck Can Help:** One Toggl subscription can feed a Hookdeck source connected to several destinations, each with its own [filter](/docs/filters), so the subscription limit no longer has to match your number of consumers.

### Unpublished retry policy

**The Problem:** You cannot tell how long an outage can last before time entry changes are lost.

**Why It Happens:** Toggl says it retries non-`2xx` responses "some time later", without a schedule, count or timeout.

**Workarounds:**

* Respond quickly and keep the endpoint available.
* After an outage, reconcile changed entities through Toggl's API.

**How Hookdeck Can Help:** Hookdeck accepts Toggl's requests while your service is down and [retries](/docs/retries) towards it on a schedule you control.

## Best practices

### Verify against the raw body

Toggl signs the full unparsed body. Mount `express.raw()` on the route, use `await request.text()` in Next.js, or `await request.body()` in FastAPI.

### Use a distinct secret per subscription

Separate secrets stop events from one subscription verifying at another's endpoint. Check `url_callback` as well.

### Keep the creator account in mind

Events are filtered by the permissions of the user who created the subscription. Create subscriptions from an account that can see every entity you need, typically a workspace admin.

### Acknowledge fast and process asynchronously

Verify, enqueue and return `2xx`. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Toggl Track webhooks are subscriptions with `{entity, action}` filters that must be enabled and validated before they deliver. Each request carries `sha256=` and a hex HMAC-SHA256 of the raw body in `X-Webhook-Signature-256`. Verify the raw bytes, check `url_callback` and `timestamp`, validate new subscriptions, and capture real payloads before relying on field names.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies each Toggl request, shows you the validation `PING` and real payloads, and holds events until your service has processed them.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Toggl webhooks reliably in minutes.