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:

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:

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:

Retry policy

The default retry schedule, in seconds since the previous attempt:

AttemptWait beforeCumulative
100s
230s30s
32m2m 30s
410m12m 30s
530m42m 30s
61h1h 42m
7–122h, 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:

ReasonMeaning
http_2xxDestination accepted (terminal)
http_4xxClient error from destination — retried unless retry_on_4xx: false
http_5xxServer error — retried
timeoutNo response within 30s — retried
connect_errorTCP/TLS failure — retried
dns_errorDestination 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

FieldTypeNotes
destinationstring | string[]Required. Absolute https:// URL.
payloadobjectRequired. Up to 256 KiB JSON.
headersobjectOptional. Forwarded as-is. Keys must match ^[A-Za-z0-9-]+$.
endpoint_idstringOptional. Use a registered endpoint by id instead of a URL.
idempotency_keystringOptional. 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

ParamDefaultNotes
statusFilter by event status.
endpoint_idFilter by registered endpoint.
sinceRFC 3339 timestamp.
limit50Max 200.
cursorFrom 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:

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:

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).