Task Tracker can send HTTP POST requests to your server when task occurrences change state. Each webhook subscribes to one or more event types and only receives deliveries for its subscribed events.
task.triggered — a new occurrence was created in pending state, either by the scheduler or a manual trigger. Occurrences created during a notification cooldown do not fire this event.task.completed — a pending occurrence was marked complete (or a task was completed ad-hoc without a pending occurrence).task.skipped — a pending occurrence was skipped.task.missed — a prior pending occurrence was auto-marked missed because a newer occurrence was created before it was resolved.task.invalidated — a previously completed occurrence was invalidated (completion reversed).The webhook payload is a JSON object containing details about the event.
{
"event": "task.triggered",
"task": {
"id": 1,
"name": "Change Air Filter",
"url": "https://tasks.newstrom.life/tasks/1"
},
"delivered_at": "2026-04-09T19:00:00Z"
}
Each delivery includes the following headers:
Content-Type: application/jsonX-Tasktracker-Signature: HMAC signature for verificationWe use HMAC SHA-256 to sign webhook payloads, protecting against tampering and ensuring the request came from Task Tracker. The signature is provided in the X-Tasktracker-Signature header.
The header format is: t=<timestamp>,v1=<signature>
Note on Webhook Secret: The secret used to compute the HMAC signature is generated and displayed to you exactly once in the UI when you create a new webhook.
v1= field, always use a timing-safe / constant-time string compare (e.g. Python's hmac.compare_digest, Ruby's ActiveSupport::SecurityUtils.secure_compare or OpenSSL.fixed_length_secure_compare, Node's crypto.timingSafeEqual). A plain == short-circuits on the first differing byte and leaks signature bytes to a remote attacker through response-time differences, letting them forge a valid signature one byte at a time. The examples below all do this correctly.import hmac
import hashlib
def valid_signature(payload_body, header, secret):
try:
parts = {}
for part in header.split(","):
key_val = part.split("=")
if len(key_val) == 2:
parts[key_val[0]] = key_val[1]
t = parts.get("t")
v1 = parts.get("v1")
except ValueError:
return False
signed_payload = f"{t}.{payload_body}".encode('utf-8')
expected_signature = hmac.new(secret.encode('utf-8'), signed_payload, hashlib.sha256).hexdigest()
# Always use a timing-safe comparison
return hmac.compare_digest(expected_signature, v1)
def valid_signature?(payload_body, header, secret)
t, v1 = header.split(",").map { |part| part.split("=").last }
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{payload_body}")
# Always use a timing-safe comparison
ActiveSupport::SecurityUtils.secure_compare(expected_signature, v1)
end
const crypto = require('crypto');
function validSignature(payloadBody, header, secret) {
const parts = header.split(',').reduce((acc, part) => {
const [key, value] = part.split('=');
acc[key] = value;
return acc;
}, {});
const t = parts['t'];
const v1 = parts['v1'];
const signedPayload = `${t}.${payloadBody}`;
const expectedSignature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
// Always use a timing-safe comparison
return crypto.timingSafeEqual(Buffer.from(expectedSignature), Buffer.from(v1));
}
If your server does not respond with a 2xx success code, Task Tracker will automatically retry the delivery with exponential backoff and jitter.
rand(0..exp)).2xx: Success. The delivery is marked as succeeded.408, 429, 5xx: Temporary failure. Triggers a retry if attempts remain.4xx: Terminal failure. The delivery is marked as failed immediately. No further retries are attempted.If all retries are exhausted without success, the delivery is marked as exhausted. It will be visible in your Deliveries dashboard, and you can see the next_attempt_at time for any deliveries that are still pending retries.