Gareth Wilson Gareth Wilson

Guide to Toggl Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationWebhooks API, https://api.track.toggl.com/webhooks/api/v1
Subscription modelPer workspace and creator; event filters of {entity, action}
PermissionsEvents are filtered by what the creator can see
ValidationRequired before events flow: echo validation_code or call the validate endpoint
Signature headerX-Webhook-Signature-256
Schemesha256= followed by lowercase hex HMAC-SHA256
Signed contentThe full unparsed body
SecretSet when creating the subscription, or generated by Toggl
Replay protectionBody timestamp (refreshed on each attempt) and url_callback
Retries"Some time later"; schedule, count and timeout not published
Test eventPING, sendable on demand
LimitsFree 1 webhook (3 events), Starter 2 (6), Premium 3 (12), Enterprise custom; per workspace
Source IPsNot published

Common events

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

EntityActions
time_entrycreated, updated, deleted
projectcreated, updated, deleted
clientcreated, updated, deleted
taskcreated, updated, deleted
tagcreated, updated, deleted
project_groupcreated, updated, deleted
project_usercreated, updated, deleted
workspace_usercreated, updated, deleted
timesheetcreated, updated, deleted
workspaceupdated

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

{ "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:

{"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:

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.

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:

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

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 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 for free and handle Toggl webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.