| title | Webhooks and scheduler |
|---|---|
| description | Send timer, project, and share events to your own endpoints, Make, Zapier, n8n, or another automation service. |
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.
- Open Settings, then Webhooks.
- Create a webhook with an HTTPS endpoint URL.
- Copy the signing secret and store it outside source control.
- Send a test webhook to verify the receiver.
- Verify
tickward-signaturein your receiver before trusting the payload. - Run the scheduler tick every minute.
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.createdproject.updatedproject.deletedtimer.createdtimer.updatedtimer.archivedtimer.restoredtimer.deletedtimer.endedshare.createdshare.deleted
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.
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".
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.
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.
Use this when you are testing, mapping fields, or building a low-risk workflow. This mode does not verify the HMAC signature.
| Setting | Value |
|---|---|
| Webhook name | Use a short name such as tickward-events. |
| API Key authentication | Leave disabled for now. Make expects x-make-apikey, and tickward sends HMAC signing headers instead. |
| IP restrictions | Leave empty unless your tickward deployment has fixed outbound IPs. |
| Data structure | Leave empty at first, then use Send in tickward so Make can detect the payload fields. |
| Get request headers | No, unless you want to inspect delivery headers. |
| Get request HTTP method | No, unless you want it for debugging. tickward sends POST. |
| JSON pass-through | No. 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.
Use this for production workflows, workflows that change external systems, or anything that sends messages, bills users, updates records, or deletes data.
Webhook settings:
| Setting | Value |
|---|---|
| Get request headers | Yes. This exposes tickward-signature, tickward-event-id, tickward-delivery-id, and tickward-event-type. |
| JSON pass-through | Yes. Make keeps the original JSON body as text, which is required for signature verification. |
Example module names:
| Step | Module | Example name |
|---|---|---|
| 1 | Webhooks > Custom webhook |
Webhook |
| 2 | Tools > Set multiple variables |
Signature |
| 3 | Make Functions > Hash functions |
HMAC |
| 4 | Connection filter after the hash step | Valid |
| 5 | JSON > Parse JSON |
Parse |
| 6 | Your workflow action | Handle |
The tickward-signature header has this shape:
t=1780000000,v1=example_signatureUse 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.
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.
| Field | Value |
|---|---|
Operation |
SHA-256 |
Data |
map signed_payload from Signature |
HMAC key |
the endpoint signing secret shown once in tickward |
Key encoding |
UTF-8 |
Output encoding |
Hex |
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.resultAfter 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.
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.
To verify the HMAC signature you need the raw request body, so configure the
Webhook node
first:
| Setting | Value |
|---|---|
| HTTP Method | POST |
| Raw Body | Yes (under node options). The body arrives as binary data instead of parsed JSON. |
| Response | Immediately, 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.
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.
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.
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 field | Value |
|---|---|
raw_body |
the unparsed request body from the raw hook |
tickward_signature |
the tickward-signature request header |
signing_secret |
the 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.
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.
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.
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=25Self-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_SECRETTest 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.