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>, where t is the Unix time (seconds since the epoch) at which we generated the delivery.

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.
Protect against replay attacks. The t field is included inside the signed payload, so an attacker cannot alter it without invalidating the signature. Always verify that t is recent — we recommend rejecting any delivery whose timestamp is more than 5 minutes (300 seconds) away from your current clock. Without this check, an attacker who captures a single valid delivery can replay it indefinitely. The examples below all enforce a 5-minute tolerance.
import hmac
import hashlib
import time

# Reject deliveries whose timestamp is more than 5 minutes from now.
TOLERANCE_SECONDS = 300

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

    if t is None or v1 is None:
        return False

    # Reject replayed (stale or far-future) deliveries.
    try:
        if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
            return False
    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)
# Reject deliveries whose timestamp is more than 5 minutes from now.
TOLERANCE_SECONDS = 300

def valid_signature?(payload_body, header, secret)
  t, v1 = header.split(",").map { |part| part.split("=").last }
  return false if t.nil? || v1.nil?

  # Reject replayed (stale or far-future) deliveries.
  return false if (Time.now.to_i - t.to_i).abs > TOLERANCE_SECONDS

  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');

// Reject deliveries whose timestamp is more than 5 minutes from now.
const TOLERANCE_SECONDS = 300;

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'];

  if (!t || !v1) {
    return false;
  }

  // Reject replayed (stale or far-future) deliveries.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) {
    return false;
  }

  const signedPayload = `${t}.${payloadBody}`;
  const expectedSignature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

  const expectedBuffer = Buffer.from(expectedSignature);
  const providedBuffer = Buffer.from(v1);

  // timingSafeEqual requires equal-length buffers.
  if (expectedBuffer.length !== providedBuffer.length) {
    return false;
  }

  // Always use a timing-safe comparison
  return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}

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.