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:
{ "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:
- Synchronous: your endpoint responds to the
PINGwith the code in the body:{"validation_.code": "<validation_ code>"} - Asynchronous: you make a
GETrequest tohttps://api.track.toggl.com/webhooks/api/v1/validate/{workspace_. Toggl returnsid}/{subscription_ id}/{validation_ code} OKand marks the subscription validated. The endpoint returns400if the code is not found or the subscription is already validated.
To trigger a PING at any time, call POST /ping/{workspace_.
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 validationPING, 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.