Execute HTTP calls at specific times, at scale, without the complexity.
A self-hosted webhook scheduler. Single binary. Redis backend. Nothing else.
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.
A service that exposes an API to create scheduled tasks (one-shot or recurring) and executes an HTTP webhook when the time arrives.
- 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.
| 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" |
docker run -d --name redis -p 6379:6379 redis:7-alpine# 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-tokenOne-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"
}
}'# 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"When TickHook fires a webhook, your endpoint receives:
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}| 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 |
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' });
});| 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 |
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
| Type | Behavior |
|---|---|
| One-shot | Executes once, deleted on success, kept on failure for inspection |
| Recurring | Executes repeatedly at fixed intervals, always schedules next run |
Backoff: base_ms * 2^(attempt-1) + random jitter (0-250ms), capped at 10 minutes.
TickHook prevents overwhelming external APIs:
--max-inflight: Total concurrent webhooks (default: 200)--max-per-domain: Per destination domain (default: 5)
docker pull ghcr.io/cr0hn/tickhook:latestDownload 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.exegit clone https://github.com/cr0hn/tickhook.git
cd tickhook
go build -o tickhook ./cmd/tickhook| 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) |
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 |
- Persistence: Jobs survive process restarts (stored in Redis)
- Retries: Failed webhooks retry with exponential backoff
- Concurrency control: Won't overwhelm your targets
- 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
- High availability with lease-based job claiming
- Prometheus metrics endpoint
- Multiple consumer support
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.
- API Reference — Complete endpoint documentation
- OpenAPI Spec — OpenAPI 3.1 specification (Swagger/Postman compatible)
- Architecture — System design and internals
- Deployment — Docker, Kubernetes, systemd guides
- Internals — Redis data model, execution details
Cron works for static, hand-defined jobs. TickHook handles dynamic, user-created schedules at scale with retries and concurrency control.
Works initially, but contention grows with scale. Redis ZSET provides efficient time-based queries with minimal overhead.
Those are general job queues requiring workers that execute code. TickHook only fires HTTP — your code runs on the receiving end.
MIT License. See LICENSE.

