Introduction
HookCastle is a managed webhook delivery service. You publish events through our HTTP API, configure one or more destination endpoints, and we handle delivery — including retries with backoff, queueing, signature generation, and a searchable delivery log.
This page is the canonical reference. It is intentionally one long document so you can Ctrl/⌘+F through it. If you prefer a TL;DR, jump to Quickstart.
Status: v2.4 is the current major. The API has been stable since v2.0 (Aug 2025). Breaking changes are versioned in the URL (/v1/→/v2/); we currently expose only/v1/and have no plan to retire it before 2027.
Quickstart
1. Get an API key
Sign in at hookcastle.com/login, create a project, and copy the API key from the dashboard. Keys come in two flavours:
hc_live_…— production, counts against your quota.hc_test_…— sandbox, never delivers but logs everything. Free of charge.
2. Send your first event
curl -X POST https://api.hookcastle.com/v1/events \
-H "Authorization: Bearer hc_test_abcd1234" \
-H "Content-Type: application/json" \
-d '{
"destination": "https://example.com/hook",
"payload": { "event": "user.created", "user_id": "u_42" }
}'
3. Check the result
$ curl https://api.hookcastle.com/v1/events/evt_8a3f4b9c \
-H "Authorization: Bearer hc_test_abcd1234"
{
"id": "evt_8a3f4b9c",
"status": "delivered",
"destination": "https://example.com/hook",
"attempts": 1,
"last_attempt": {
"at": "2026-04-30T09:14:23Z",
"response_status": 200,
"duration_ms": 87
}
}
That's it. Real integrations usually replace step 2 with an SDK call inside your service. See SDKs.
Authentication
Every request must carry a Bearer token in the Authorization header:
Authorization: Bearer hc_live_xxxxxxxxxxxx
Keys are scoped to a single project. They can be rotated from the dashboard; rotation gives you a 24h grace period during which both old and new keys accept requests. After 24h the old key returns 401 Unauthorized.
If you leak a key publicly (committed to a public repo, posted in chat by mistake): rotate immediately and email hello@hookcastle.com. We grep the public GitHub event firehose for the hc_live_ prefix and rotate proactively, but don't rely on us catching it for you.
Events
An event is a single payload destined for one or more endpoints. Events are immutable once created. They have:
id— a uniqueevt_-prefixed identifier.destination— a URL, or an array of URLs (fan-out).payload— arbitrary JSON, up to 256 KiB.headers— optional custom headers to forward.status— one ofqueued,delivering,delivered,failed,discarded.
Endpoints
An endpoint is a destination URL with optional configuration. You can send events to a URL without pre-registering it (we'll deliver to anything that resolves), but registered endpoints unlock:
- A signing secret — used to compute
X-HookCastle-Signature. - A retry policy — overrides the global default.
- A rate limit — max requests/sec to that destination.
- Custom headers — appended on every delivery.
Retry policy
The default retry schedule, in seconds since the previous attempt:
| Attempt | Wait before | Cumulative |
|---|---|---|
| 1 | 0 | 0s |
| 2 | 30s | 30s |
| 3 | 2m | 2m 30s |
| 4 | 10m | 12m 30s |
| 5 | 30m | 42m 30s |
| 6 | 1h | 1h 42m |
| 7–12 | 2h, 4h, 6h, 6h, 6h, 6h | ~24h total |
A delivery is considered successful when the destination returns a 2xx response within 30 seconds. Anything else (3xx, 4xx, 5xx, timeout, connection error) counts as a failed attempt and triggers a retry. After 12 failed attempts the event is marked failed and you'll see it surfaced in the dashboard.
You can override the schedule per-endpoint:
{
"retry_policy": {
"schedule": [10, 60, 300, 1800],
"max_attempts": 4,
"retry_on_4xx": false
}
}
Errors & states
Event status state machine:
queued → delivering → delivered
↘
failed (retries exhausted)
↘
discarded (manually cancelled or destination de-listed)
Delivery attempts have a finer-grained reason in the log:
| Reason | Meaning |
|---|---|
http_2xx | Destination accepted (terminal) |
http_4xx | Client error from destination — retried unless retry_on_4xx: false |
http_5xx | Server error — retried |
timeout | No response within 30s — retried |
connect_error | TCP/TLS failure — retried |
dns_error | Destination hostname did not resolve — retried with longer initial backoff |
API reference
POST/v1/events
Create a new event for delivery. Returns immediately — delivery happens out of band.
Request body
| Field | Type | Notes |
|---|---|---|
destination | string | string[] | Required. Absolute https:// URL. |
payload | object | Required. Up to 256 KiB JSON. |
headers | object | Optional. Forwarded as-is. Keys must match ^[A-Za-z0-9-]+$. |
endpoint_id | string | Optional. Use a registered endpoint by id instead of a URL. |
idempotency_key | string | Optional. Same key within 24h returns the original event. |
Response
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "evt_8a3f4b9c7d",
"status": "queued",
"destination": "https://acme.app/hook",
"created_at": "2026-04-30T09:14:22Z",
"attempts": 0
}
GET/v1/events
List events with cursor-based pagination. Newest first.
Query parameters
| Param | Default | Notes |
|---|---|---|
status | — | Filter by event status. |
endpoint_id | — | Filter by registered endpoint. |
since | — | RFC 3339 timestamp. |
limit | 50 | Max 200. |
cursor | — | From next_cursor in the previous page. |
$ curl 'https://api.hookcastle.com/v1/events?status=failed&limit=20' \
-H "Authorization: Bearer hc_live_xxx"
GET/v1/events/:id
Retrieve a single event by id, including the full delivery log.
{
"id": "evt_8a3f4b9c",
"status": "delivered",
"destination": "https://acme.app/hook",
"payload": { "event": "order.paid", "id": "ord_4912" },
"attempts": 2,
"log": [
{
"at": "2026-04-30T09:14:22Z",
"duration_ms": 30001,
"result": "timeout",
"response_status": null
},
{
"at": "2026-04-30T09:14:53Z",
"duration_ms": 142,
"result": "http_2xx",
"response_status": 200,
"response_headers": { "content-type": "text/plain" }
}
]
}
POST/v1/events/:id/replay
Re-deliver an event. The original payload is sent to the original destination (or a different one passed in the body). A new event id is created — the original event is not mutated.
Endpoints CRUD
Standard REST shape under /v1/endpoints. The dashboard has full UI for this so most users never call these endpoints directly. See the CLI README for a worked example.
Verify signatures
Each delivery includes an HMAC-SHA256 signature in X-HookCastle-Signature, computed over the raw request body using the endpoint secret. The header value is in the form t=<unix_ts>,v1=<hex_signature> — the timestamp is to defend against replay attacks. We sign t.body (timestamp, dot, body), not just the body.
Node.js
// raw body required — disable JSON parsing for this route
const crypto = require('crypto');
function verify(req, secret, toleranceSec = 300) {
const header = req.headers['x-hookcastle-signature'] || '';
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const ts = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1 || '', 'hex')
);
}
Python (Flask)
import hmac, hashlib, time
from flask import request, abort
def verify(secret, tolerance=300):
header = request.headers.get('X-HookCastle-Signature', '')
parts = dict(p.split('=') for p in header.split(','))
ts = int(parts.get('t', '0'))
if abs(time.time() - ts) > tolerance:
abort(400, 'stale signature')
signed_payload = f"{ts}.".encode() + request.get_data()
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get('v1', '')):
abort(400, 'bad signature')
Go
func Verify(r *http.Request, body []byte, secret string) bool {
h := r.Header.Get("X-HookCastle-Signature")
var ts, sig string
for _, p := range strings.Split(h, ",") {
kv := strings.SplitN(p, "=", 2)
switch kv[0] {
case "t": ts = kv[1]
case "v1": sig = kv[1]
}
}
if t, _ := strconv.ParseInt(ts, 10, 64); math.Abs(float64(time.Now().Unix()-t)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
Rate limits
API rate limits, per project:
- Free: 50 req/s burst, 20 req/s sustained.
- Pro: 200 req/s burst, 100 req/s sustained.
- Enterprise: negotiated.
When you exceed the limit you get 429 Too Many Requests with a Retry-After header. The SDKs honour it automatically.
SDKs & examples
Maintained on github.com/hookcastle:
hookcastle-node— TypeScript / Node 18+hookcastle-python— Python 3.10+hookcastle-go— Go 1.21+hookcastle-examples— short scripts in Ruby, PHP, Elixir, Rust
CLI
$ brew install hookcastle/tap/hookcastle # macOS $ hookcastle login $ hookcastle events tail --status failed # follow failed deliveries $ hookcastle events replay evt_8a3f4b9c # one-shot replay
Found a typo, or have a doc improvement to suggest? Email hello@hookcastle.com — or open a PR on the public docs repo (link coming with v2.5).