TickHook

Execute HTTP calls at specific times, at scale, without the complexity.
A self-hosted webhook scheduler. Single binary. Redis backend. Nothing else.
The Problem
You start with simple needs:
- "Send this email tomorrow at 8:00 AM"
- "Call this endpoint every 6 hours"
- "Fire a reminder in 15 minutes"
- "Refresh this data daily at midnight"
So you use cron, a sleep(), or poll your database. It works... until it doesn't:
- Users create schedules dynamically (not static cron jobs)
- You need persistence across restarts and deployments
- You want consistent retries with backoff
- You need to avoid hammering external APIs (rate limits)
- You want something simple to operate
TickHook solves this: schedule webhooks with an API, and they fire when the time comes.
What TickHook Is
A service that exposes an API to create scheduled tasks (one-shot or recurring) and executes an HTTP webhook when the time arrives.
Your App → POST /v1/jobs/one-shot → TickHook stores in Redis
↓
Time arrives
↓
TickHook fires webhook → Your endpoint
What TickHook Is NOT
- Not a workflow engine — no steps, conditions, or branching
- Not distributed cron — no complex cron expressions
- Not a general job queue — doesn't execute arbitrary code
- Not exactly-once — at-least-once delivery (make receivers idempotent)
TickHook is deliberately small: timestamps, intervals, HTTP. That's it.
Use Cases
| Scenario |
Example |
| Scheduled notifications |
"Send newsletter daily at user's preferred time" |
| Drip campaigns |
"Email 1 now, Email 2 tomorrow 8 AM, Email 3 in 3 days" |
| Retry failed integrations |
"Webhook failed? Retry in 5 minutes with backoff" |
| Expirations |
"Revoke access token at midnight" |
| Reminders |
"Notify user 30 minutes before event" |
| Periodic syncs |
"Call sync endpoint every hour" |
Quick Start
1. Start Redis
docker run -d --name redis -p 6379:6379 redis:7-alpine
2. Run TickHook
# Docker
docker run -d -p 8080:8080 ghcr.io/cr0hn/tickhook:latest \
--redis-url redis://host.docker.internal:6379 \
--auth-token my-secret-token
# Or binary
./tickhook --redis-url redis://localhost:6379 --auth-token my-secret-token
3. Schedule Your First Webhook
One-shot — fires once at a specific time:
curl -X POST http://localhost:8080/v1/jobs/one-shot \
-H "Authorization: Bearer my-secret-token" \
-H "Content-Type: application/json" \
-d '{
"execute_at": "2026-01-15T10:00:00Z",
"webhook": {
"url": "https://your-api.com/webhook",
"method": "POST",
"headers": {"X-Custom-Header": "my-value"},
"body": {"event": "reminder", "user_id": 123}
}
}'
Recurring — fires every N milliseconds:
curl -X POST http://localhost:8080/v1/jobs/recurring \
-H "Authorization: Bearer my-secret-token" \
-H "Content-Type: application/json" \
-d '{
"start_at": "2026-01-15T00:00:00Z",
"interval_ms": 86400000,
"webhook": {
"url": "https://your-api.com/daily-sync",
"method": "POST"
}
}'
4. Manage Jobs
# Get job status
curl http://localhost:8080/v1/jobs/{job_id} \
-H "Authorization: Bearer my-secret-token"
# Cancel a job
curl -X DELETE http://localhost:8080/v1/jobs/{job_id} \
-H "Authorization: Bearer my-secret-token"
Receiving Webhooks
When TickHook fires a webhook, your endpoint receives:
HTTP Request
POST /your-endpoint HTTP/1.1
Host: your-api.com
Content-Type: application/json
X-Job-Id: 550e8400-e29b-41d4-a716-446655440000
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
X-Custom-Header: my-value
{"event": "reminder", "user_id": 123}
What Your Endpoint Receives
| Component |
Description |
| Method |
The HTTP method you specified (POST, GET, etc.) |
| URL |
Your endpoint URL |
| Headers |
Your custom headers + automatic TickHook headers |
| Body |
The exact JSON body you provided when creating the job |
TickHook adds these headers to every webhook (cannot be overridden):
| Header |
Value |
Purpose |
X-Job-Id |
UUID |
Unique job identifier |
Idempotency-Key |
UUID (same as X-Job-Id) |
For deduplication |
Content-Type |
application/json |
When body is present |
Example Receiver (Node.js)
app.post('/webhook', (req, res) => {
const jobId = req.headers['x-job-id'];
const idempotencyKey = req.headers['idempotency-key'];
// Check if already processed (idempotency)
if (await alreadyProcessed(idempotencyKey)) {
return res.status(200).json({ status: 'already_processed' });
}
// Process the webhook
const { event, user_id } = req.body;
await processEvent(event, user_id);
// Mark as processed
await markProcessed(idempotencyKey);
res.status(200).json({ status: 'ok' });
});
Response Handling
| Your Response |
TickHook Action |
| 2xx |
Success — job completes |
| 5xx, 429 |
Retry with exponential backoff |
| 4xx (except 429) |
Fail immediately — no retry |
| Timeout / network error |
Retry with backoff |
How It Works
TickHook maintains a time-ordered list of jobs in Redis:
- Every 200ms, it checks: "which jobs are due now?"
- Executes them in parallel, respecting concurrency limits
- On success: one-shot jobs are deleted, recurring jobs schedule the next run
- On failure: retries with exponential backoff
Job Types
| Type |
Behavior |
| One-shot |
Executes once, deleted on success, kept on failure for inspection |
| Recurring |
Executes repeatedly at fixed intervals, always schedules next run |
Retry Logic
Backoff: base_ms * 2^(attempt-1) + random jitter (0-250ms), capped at 10 minutes.
Concurrency Control
TickHook prevents overwhelming external APIs:
--max-inflight: Total concurrent webhooks (default: 200)
--max-per-domain: Per destination domain (default: 5)
Installation
Docker (Recommended)
docker pull ghcr.io/cr0hn/tickhook:latest
Pre-built Binaries
Download from GitHub Releases:
# Linux
wget https://github.com/cr0hn/tickhook/releases/latest/download/tickhook-linux-amd64
# macOS
wget https://github.com/cr0hn/tickhook/releases/latest/download/tickhook-darwin-arm64
# Windows
Invoke-WebRequest -Uri https://github.com/cr0hn/tickhook/releases/latest/download/tickhook-windows-amd64.exe -OutFile tickhook.exe
From Source
git clone https://github.com/cr0hn/tickhook.git
cd tickhook
go build -o tickhook ./cmd/tickhook
Configuration
CLI Flags
| Flag |
Required |
Default |
Description |
--redis-url |
Yes |
— |
Redis connection URL |
--auth-token |
Yes |
— |
Bearer token for API authentication |
--bind |
No |
0.0.0.0:8080 |
HTTP server bind address |
--namespace |
No |
tickhook |
Redis key prefix |
--poll-ms |
No |
200 |
How often to check for due jobs (ms) |
--batch |
No |
200 |
Max jobs to fetch per poll |
--max-inflight |
No |
200 |
Max concurrent webhook executions |
--max-per-domain |
No |
5 |
Max concurrent per destination domain |
--default-timeout-ms |
No |
5000 |
Default webhook timeout (ms) |
--log-level |
No |
info |
Log verbosity (debug, info, warn, error) |
Docker Compose Example
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
tickhook:
image: ghcr.io/cr0hn/tickhook:latest
ports:
- "8080:8080"
command: >
--redis-url redis://redis:6379
--auth-token your-secret-token
--max-inflight 500
--max-per-domain 10
depends_on:
- redis
| Metric |
Value |
| Binary size |
6.2 MB |
| Memory (idle) |
~8 MB |
| Memory (under load) |
~17 MB |
| API throughput |
46,000 req/s |
| Job creation rate |
20,000 req/s |
Guarantees and Limitations
What TickHook Guarantees
- Persistence: Jobs survive process restarts (stored in Redis)
- Retries: Failed webhooks retry with exponential backoff
- Concurrency control: Won't overwhelm your targets
V1 Limitations
- Single consumer: One process instance (no HA yet)
- At-least-once: Webhooks may fire more than once — receivers must be idempotent
- No cron expressions: Only fixed intervals (every N ms)
- UTC only: All times in UTC, handle timezone conversion client-side
- Crash window: If the process dies between claiming and executing a job, that job may be lost
V2 Roadmap
- High availability with lease-based job claiming
- Prometheus metrics endpoint
- Multiple consumer support
API Reference
All endpoints require Authorization: Bearer <token>.
| Method |
Endpoint |
Description |
| POST |
/v1/jobs/one-shot |
Create one-shot job |
| POST |
/v1/jobs/recurring |
Create recurring job |
| GET |
/v1/jobs/{job_id} |
Get job details |
| DELETE |
/v1/jobs/{job_id} |
Cancel/delete job |
| GET |
/health |
Health check (no auth) |
See docs/API.md for complete request/response examples.
Documentation
Why Not Just Use...
Cron?
Cron works for static, hand-defined jobs. TickHook handles dynamic, user-created schedules at scale with retries and concurrency control.
Postgres polling?
Works initially, but contention grows with scale. Redis ZSET provides efficient time-based queries with minimal overhead.
Celery/Sidekiq?
Those are general job queues requiring workers that execute code. TickHook only fires HTTP — your code runs on the receiving end.
License
MIT License. See LICENSE.