Webhooks

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.

Event Types

  • 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).

Payload Format

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"
}

Headers

Each delivery includes the following headers:

  • Content-Type: application/json
  • X-Tasktracker-Signature: HMAC signature for verification

HMAC Validation

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

Use a constant-time comparison. When you compare the expected signature to the value in the 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));
}

Retry Semantics

If your server does not respond with a 2xx success code, Task Tracker will automatically retry the delivery with exponential backoff and jitter.

  • Maximum attempts: 3
  • Backoff strategy: Base 30 seconds, doubling, capped at 1 hour, with full jitter (rand(0..exp)).
  • Example:
    • Attempt 1 fails.
    • Attempt 2 waits between 0 and 30 seconds.
    • Attempt 3 waits between 0 and 60 seconds.

HTTP Status Handling

  • 2xx: Success. The delivery is marked as succeeded.
  • 408, 429, 5xx: Temporary failure. Triggers a retry if attempts remain.
  • Other 4xx: Terminal failure. The delivery is marked as failed immediately. No further retries are attempted.

Final Failure

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.