Skip to main content
tickward can send account events to HTTPS webhook endpoints. Webhooks are useful for automation tools and for self-hosted workers that react to timer changes.

Quick setup

  1. Open Settings, then Webhooks.
  2. Create a webhook with an HTTPS endpoint URL.
  3. Copy the signing secret and store it outside source control.
  4. Send a test webhook to verify the receiver.
  5. Verify tickward-signature in your receiver before trusting the payload.
  6. Run the scheduler tick every minute.

Events

Webhook payloads use a stable event envelope:
{
  "object": "event",
  "id": "evt_123",
  "type": "timer.ended",
  "created": "2026-06-09T09:00:00.000Z",
  "environment": "production",
  "event_version": "2026-06-10",
  "data": {
    "object": {
      "object": "timer",
      "id": "timer_123",
      "project_id": "project_123",
      "project_name": "Main",
      "timer_id": "timer_123",
      "timer_label": "Renewal"
    }
  }
}
event_version is a date-based payload contract version. environment comes from TICKWARD_ENVIRONMENT when configured, otherwise it falls back to production, development, or test. Supported event types:
  • project.created
  • project.updated
  • project.deleted
  • timer.created
  • timer.updated
  • timer.archived
  • timer.restored
  • timer.deleted
  • timer.ended
  • share.created
  • share.deleted

API management

Webhook endpoints can be managed from Settings or through the public API:
curl "https://tickward.com/api/v1/webhooks" \
  -H "Authorization: Bearer $TICKWARD_API_KEY"
Use PATCH /webhooks/{webhook_id} to edit the subscribed event_types or to disable and enable an endpoint:
{
  "event_types": ["timer.ended", "share.created"]
}
{
  "status": "disabled"
}
DELETE /webhooks/{webhook_id} removes the endpoint and its delivery history. Use disable when you only want to pause deliveries.

Test delivery

Use Send in Settings to send a one-off webhook.test event to the endpoint immediately. Test deliveries are rate limited and do not retry automatically. They use the same signing headers and payload envelope as normal webhook deliveries. The test payload identifies the tested endpoint:
{
  "type": "webhook.test",
  "data": {
    "object": {
      "object": "webhook_endpoint",
      "webhook_endpoint_name": "Production automation"
    }
  }
}
This only confirms delivery and signature verification. Real timer events use data.object.object: "timer", project events use "project", and share events use "share".

Sample events

The Send menu can also send a sample of any event type the endpoint is subscribed to, for example timer.ended. Sample events use the same envelope and signing as real deliveries, with placeholder data such as "id": "timer_sample" and "timer_label": "Sample timer". Use them to map fields in Make, Zapier, or n8n without waiting for a real timer to end.

Make.com setup

Create a Webhooks > Custom webhook trigger in Make, copy its webhook URL, and use that URL as the tickward endpoint. Choose one of two setup modes.

Quick setup

Use this when you are testing, mapping fields, or building a low-risk workflow. This mode does not verify the HMAC signature.
SettingValue
Webhook nameUse a short name such as tickward-events.
API Key authenticationLeave disabled for now. Make expects x-make-apikey, and tickward sends HMAC signing headers instead.
IP restrictionsLeave empty unless your tickward deployment has fixed outbound IPs.
Data structureLeave empty at first, then use Send in tickward so Make can detect the payload fields.
Get request headersNo, unless you want to inspect delivery headers.
Get request HTTP methodNo, unless you want it for debugging. tickward sends POST.
JSON pass-throughNo. Make will parse the JSON body into fields you can map in later steps.
After saving the Make webhook, click Send in tickward. Make should detect fields such as type, environment, event_version, and data.object.object. For the test event, data.object.object is webhook_endpoint. Timer deliveries use timer fields such as data.object.timer_label.

Verified setup

Use this for production workflows, workflows that change external systems, or anything that sends messages, bills users, updates records, or deletes data. Webhook settings:
SettingValue
Get request headersYes. This exposes tickward-signature, tickward-event-id, tickward-delivery-id, and tickward-event-type.
JSON pass-throughYes. Make keeps the original JSON body as text, which is required for signature verification.
Example module names:
StepModuleExample name
1Webhooks > Custom webhookWebhook
2Tools > Set multiple variablesSignature
3Make Functions > Hash functionsHMAC
4Connection filter after the hash stepValid
5JSON > Parse JSONParse
6Your workflow actionHandle
The tickward-signature header has this shape:
t=1780000000,v1=example_signature
Use t as the timestamp. Use v1 as the signature you compare with the hash result. The value signed by tickward is the timestamp, a dot, and the exact raw JSON body:
1780000000.{"object":"event","id":"evt_123",...}
Do not rebuild or pretty-print the JSON before hashing. Any whitespace, key order, or encoding change produces a different signature. This is why verified Make scenarios use JSON pass-through: Yes first, then parse JSON only after the signature filter passes.

Example validated Make flow

For Make’s __IMTHEADERS__ array, the header lookup formula usually looks like this:
{{get(map(1.`__IMTHEADERS__`; "value"; "name"; "tickward-signature"); 1)}}
That expression means: from the whole headers array, return the value field for rows where name equals tickward-signature, then take the first match. Do not map __IMTHEADERS__[].name directly for the hash step. The formula needs the whole headers array as its first argument. If Make inserts the array token with [], keep the array token but remove the field access after it. In other words, use the __IMTHEADERS__ array, not __IMTHEADERS__[].name. Add Tools > Set multiple variables and name it Signature. Set the variable lifetime to One cycle, then add these variables: signature_header:
{{get(map(1.`__IMTHEADERS__`; "value"; "name"; "tickward-signature"); 1)}}
signature_timestamp:
{{replace(get(split(get(map(1.`__IMTHEADERS__`; "value"; "name"; "tickward-signature"); 1); ","); 1); "t="; "")}}
signature_v1:
{{replace(get(split(get(map(1.`__IMTHEADERS__`; "value"; "name"; "tickward-signature"); 1); ","); 2); "v1="; "")}}
signed_payload:
{{replace(get(split(get(map(1.`__IMTHEADERS__`; "value"; "name"; "tickward-signature"); 1); ","); 1); "t="; "")}}.{{1.value}}
Then add Make Functions > Hash functions and name it HMAC.
FieldValue
OperationSHA-256
Datamap signed_payload from Signature
HMAC keythe endpoint signing secret shown once in tickward
Key encodingUTF-8
Output encodingHex
Do not export a Make blueprint that contains a real signing secret. If you share the scenario, replace the HMAC key with a placeholder first. Add a filter on the connection after HMAC and name it Valid. Filter condition:
Signature.signature_v1 equals HMAC.result
After that filter, add JSON > Parse JSON and parse the raw body from the webhook trigger. Continue the rest of the scenario only from the valid path.

n8n setup

Add a Webhook trigger node, copy its production URL, and use it as the tickward endpoint URL. n8n shows a separate test URL that only works while the workflow editor is listening; register the production URL in tickward and activate the workflow. For field mapping, send a sample event from tickward (Send, then pick an event type) while the n8n workflow is listening.

HMAC verification setup

To verify the HMAC signature you need the raw request body, so configure the Webhook node first:
SettingValue
HTTP MethodPOST
Raw BodyYes (under node options). The body arrives as binary data instead of parsed JSON.
ResponseImmediately, code 200
Then add a Code node directly after the webhook:
const crypto = require("crypto")

const item = items[0]
const header = String(item.json.headers["tickward-signature"] ?? "")
const timestamp = (header.split(",")[0] ?? "").replace("t=", "")
const expected = (header.split(",")[1] ?? "").replace("v1=", "")
const rawBody = Buffer.from(item.binary.data.data, "base64").toString("utf8")

const computed = crypto
  .createHmac("sha256", $env.TICKWARD_WEBHOOK_SECRET)
  .update(`${timestamp}.${rawBody}`, "utf8")
  .digest("hex")

if (computed !== expected) {
  throw new Error("Invalid tickward signature")
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
  throw new Error("Stale tickward signature timestamp")
}

return [{ json: JSON.parse(rawBody) }]
Store the signing secret as the TICKWARD_WEBHOOK_SECRET environment variable on your n8n instance (or use n8n credentials) instead of pasting it into the node. Nodes after the Code node receive the parsed, verified event JSON. Self-hosted n8n must be reachable over HTTPS. For local experiments, a self-hosted tickward can allow http://localhost targets with TICKWARD_WEBHOOK_ALLOW_PRIVATE_NETWORKS=true.

Zapier setup

Use the Webhooks by Zapier trigger. Zapier’s Catch Hook is the simplest option for low-risk workflows because it parses the JSON body for field mapping. For workflows that change external systems, use Catch Raw Hook so the Zap receives the unparsed body and headers needed for HMAC verification.

Simple setup

Create a Webhooks by Zapier > Catch Hook trigger, copy the webhook URL into tickward, then use Send so Zapier can detect the payload fields. This is easy to map in later Zap steps, but the request is not verified.

HMAC verification setup

Create a Webhooks by Zapier > Catch Raw Hook trigger, copy the webhook URL into tickward, then send a test event. Zapier documents that Catch Raw Hook includes the unparsed request body and headers. Add Code by Zapier > Run JavaScript after the trigger. Add these input fields from the raw hook sample:
Input fieldValue
raw_bodythe unparsed request body from the raw hook
tickward_signaturethe tickward-signature request header
signing_secretthe endpoint signing secret shown once in tickward
Then use this code:
const crypto = require("crypto")

const header = String(inputData.tickward_signature ?? "")
const timestamp = (header.split(",")[0] ?? "").replace("t=", "")
const expected = (header.split(",")[1] ?? "").replace("v1=", "")
const rawBody = String(inputData.raw_body ?? "")

if (!timestamp || !expected || !rawBody || !inputData.signing_secret) {
  throw new Error("Missing tickward webhook verification input")
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
  throw new Error("Stale tickward signature timestamp")
}

const computed = crypto
  .createHmac("sha256", inputData.signing_secret)
  .update(`${timestamp}.${rawBody}`, "utf8")
  .digest("hex")

if (computed !== expected) {
  throw new Error("Invalid tickward signature")
}

const event = JSON.parse(rawBody)
output = {
  verified: true,
  event_id: event.id,
  event_type: event.type,
  event_object: event.data.object,
}
If your Zap sample does not expose the signature header cleanly, put a small verifying relay in front of Zapier. The relay verifies tickward-signature against the raw body and forwards only valid requests to the Zapier hook URL. The examples/webhook-receivers/cloudflare-worker example can be adapted into that relay.

Signing

Each endpoint has a signing secret shown once when the endpoint is created. tickward signs every delivery with:
tickward-signature: t=1780000000,v1=<hmac_sha256>
To verify a request, compute HMAC SHA-256 over:
<timestamp>.<raw request body>
Use the endpoint signing secret as the HMAC key and compare it with the v1 signature. Reject old timestamps in your receiver. Security checklist:
  • Use HTTPS endpoints in production.
  • Store the signing secret as a secret, not in source code.
  • Verify the signature against the raw request body.
  • Reject old timestamps to reduce replay risk.
  • Keep handlers idempotent because deliveries can retry.
  • Do not rely on redirects. tickward sends requests directly to the configured URL.
Production deployments block local and private network targets by default. If a self-hosted deployment must call trusted internal services, set TICKWARD_WEBHOOK_ALLOW_PRIVATE_NETWORKS=true and keep those endpoints private.

Delivery

Mutations write events to the database first. A scheduler tick then dispatches due events and retries failed deliveries with backoff. This keeps API requests fast and makes delivery durable. A failed receiver does not block timer edits or API calls.

Limits and automatic disable

Each account can keep up to 3 active webhook endpoints. Creating an endpoint past the limit returns a limit_exceeded error. Disabled endpoints do not count against the limit. An endpoint that keeps failing is disabled automatically after 25 consecutive failed delivery attempts with no successful delivery in between. With the default retry policy that equals 5 events that exhausted all retries. Any successful delivery resets the failure counter. When an endpoint is disabled automatically, tickward emails the account owner if a mail provider is configured. Fix the receiver, then use Enable on the endpoint in Settings (or PATCH /api/account/webhooks/:id with {"status": "active"}). Re-enabling resets the failure counter. Use Disable when you want to pause deliveries and keep the endpoint history. Use Remove only when the endpoint and its delivery history should be deleted. Each endpoint row in Settings also shows its recent deliveries with status, HTTP response code, attempt count, and the last error - check there first when an automation platform does not receive events. Self-hosted deployments can tune both limits:
TICKWARD_WEBHOOK_MAX_ENDPOINTS=3
TICKWARD_WEBHOOK_AUTO_DISABLE_FAILURES=25

Scheduler

Self-hosted deployments should call the scheduler endpoint periodically:
curl -X POST "https://yourdomain.com/api/internal/scheduler/tick" \
  -H "Authorization: Bearer $TICKWARD_SCHEDULER_SECRET"
Set TICKWARD_SCHEDULER_SECRET in the app environment. The same secret must be used by the scheduler caller. You can run the scheduler anywhere that can reach your tickward deployment:
  • Cloudflare Cron Trigger
  • AWS EventBridge Scheduler
  • a VPS cron job
  • a container worker
  • another hosted scheduler
Cloudflare Cron Trigger is the smallest production setup for most self-hosted deployments. Keep the scheduler secret in Worker secrets and keep the app URL as a normal Wrangler variable. Worker:
type Env = {
  TICKWARD_BASE_URL: string
  TICKWARD_SCHEDULER_SECRET: string
}

export default {
  async scheduled(_controller: ScheduledController, env: Env) {
    const response = await fetch(`${env.TICKWARD_BASE_URL}/api/internal/scheduler/tick`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.TICKWARD_SCHEDULER_SECRET}`,
      },
    })

    if (!response.ok) {
      throw new Error(`tickward scheduler failed with ${response.status}`)
    }
  },
}
Wrangler config:
{
  "name": "tickward-scheduler",
  "main": "worker.ts",
  "compatibility_date": "2026-06-11",
  "workers_dev": false,
  "preview_urls": false,
  "observability": {
    "enabled": true
  },
  "vars": {
    "TICKWARD_BASE_URL": "https://yourdomain.com"
  },
  "triggers": {
    "crons": ["* * * * *"]
  }
}
Set the secret:
wrangler secret put TICKWARD_SCHEDULER_SECRET
Test locally with Wrangler, then trigger the scheduled handler:
curl "http://localhost:8787/cdn-cgi/handler/scheduled?format=json"
See examples/scheduler/cloudflare-worker for a copy-paste starter. Run it every minute for near-real-time webhook delivery. Browser-local alarms can still fire immediately while tickward is open. Long-term, the same event contract can be processed by Temporal for durable scheduled work without changing webhook receivers.