HookOps

HookOps accepts webhook events from your application, queues them in PostgreSQL, and delivers them to customer endpoints with HMAC-SHA256 signatures, exponential backoff, per-endpoint rate limiting, circuit breakers, and dead-letter queues.

About

HookOps Webhook Workflow

HookOps handles the full lifecycle of outbound webhooks. Your app publishes an event, HookOps fans it out to every subscribed endpoint, signs each payload, delivers it, and deals with failures along the way.

Where it goes further is recovery. When an endpoint crosses the failure threshold and gets disabled, HookOps doesn't just leave it there. It periodically probes the endpoint and re-enables it automatically once it starts responding again. No one has to remember to go flip it back on.

The entire system runs on Go and PostgreSQL. PostgreSQL handles both storage and job queuing via SKIP LOCKED.

Architecture

HookOps Engine Architecture

How Delivery Works

1. Event Ingestion

Your app sends a POST /api/v1/events with a JSON payload and event type. HookOps looks up every endpoint subscribed to that event type and inserts one delivery_attempt row per endpoint in a single transaction.

2. Polling Dispatcher

A background goroutine ticks on a configurable interval (default 2s) and runs a single SQL statement that atomically claims a batch of pending attempts using SELECT ... FOR UPDATE SKIP LOCKED. Rows are locked at the database level, so multiple HookOps instances can poll the same table without claiming the same task.

The dispatcher also implements adaptive drain: if a full batch is returned, it immediately queries again without waiting for the next tick. Low throughput polls lazily, high throughput drains aggressively.

3. Worker Pool

Claimed tasks are pushed into a bounded buffered channel. A fixed pool of goroutines pulls tasks, acquires a rate limiter token (if configured), signs the payload with HMAC-SHA256, sends the HTTP POST, and records the outcome back to PostgreSQL.

If the channel is full (backpressure), the dispatcher reverts unclaimed tasks to pending in the database. Nothing is lost.

4. Outcome Recording

After each delivery, the recorder:

  • Checks if the error is retryable (5xx, 429, network errors = yes; 4xx = no)
  • Schedules the next retry with exponential backoff and jitter
  • Moves exhausted attempts to the dead-letter queue
  • Updates the endpoint's failure counter and trips the circuit breaker if the threshold is crossed

Concurrency Model

ComponentMechanismWhy
Dispatcher lifecyclesync.Once for start/stop, chan struct{} for signalingPrevents double-start, ensures clean stop propagation
Worker poolFixed goroutines + bounded chan DeliveryTaskPredictable memory footprint, no unbounded goroutine spawning
Rate limiter registrysync.Map with LoadOrStoreLock-free reads on the hot path, one limiter instance per endpoint under concurrent access
Shutdown orderingHTTP > Dispatcher > Workers > DB poolReverse dependency order so no new work enters the pipeline
Panic recoveryrecover() boundary per workerA panicking delivery doesn't kill the goroutine, it logs and continues

Graceful shutdown pipeline in main.go:

  1. Stop accepting HTTP requests (5s timeout for in-flight requests)
  2. Stop the dispatcher (no more tasks claimed from the database)
  3. Close the task channel, drain remaining workers (15s timeout)
  4. Close the database connection pool

Retry Strategy

Failed deliveries follow a stepped exponential backoff schedule with ±20% uniform jitter:

AttemptBase DelayWith Jitter
1st failure30s24s to 36s
2nd failure2 min1m36s to 2m24s
3rd failure10 min8m to 12m
4th failure1 hour48m to 1h12m
5th failure4 hours3h12m to 4h (capped)

After 5 failures (configurable), the attempt moves to the dead-letter queue and can be replayed through the API.

The jitter prevents thundering herd problems when a downstream endpoint recovers.

Retryable:

5xx server errors, 429 rate limited, network timeouts, DNS failures.

Not retryable:

4xx client errors (400, 401, 403, 404, 422). The request itself is wrong, retrying won't help.

Security

Every outbound webhook is signed using HMAC-SHA256 with a per-endpoint secret. The signature is sent as X-HookOps-Signature: sha256=<hex>.

Headers sent with every webhook:

HeaderExample
X-HookOps-Signaturesha256=d3b07384d113edec49eaa6...
X-HookOps-Eventinvoice.paid
X-HookOps-Event-IDb9f5f0b4-3c1d-4e9b-b0b2-7a5f6e8c9d0a
X-HookOps-Timestamp1726425937
User-AgentHookOps-Webhook-Engine/1.0

Verifying Signatures

verify_webhook.go
Go
// Go HMAC-SHA256 signature verification
func VerifyWebhook(payload []byte, secret, signature string) bool {
    sig := strings.TrimPrefix(signature, "sha256=")
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(sig), []byte(expected))
}
verify_webhook.py
Python
# Python HMAC-SHA256 signature verification
import hmac, hashlib

def verify_webhook(payload: bytes, secret: str, signature: str) -> bool:
    sig = signature.removeprefix("sha256=")
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)

Both use constant-time comparison to prevent timing side-channel attacks.

API

Interactive Swagger docs at localhost:8080/docs.

MethodEndpointDescription
POST/api/v1/applicationsRegister a tenant (returns API key)
GET/api/v1/applicationsList applications
POST/api/v1/event-typesCreate an event type
GET/api/v1/event-typesList event types
POST/api/v1/endpointsRegister a destination URL
GET/api/v1/endpointsList endpoints
PATCH/api/v1/endpoints/:idUpdate endpoint
DELETE/api/v1/endpoints/:idRemove endpoint
POST/api/v1/endpoints/:id/subscriptionsSubscribe endpoint to event type
DELETE/api/v1/endpoints/:eid/subscriptions/:sidUnsubscribe
POST/api/v1/eventsPublish event (triggers fan-out)
GET/api/v1/events/:id/deliveriesDelivery attempts for an event
POST/api/v1/deliveries/:id/retryRetry a failed delivery
POST/api/v1/events/:id/replayReplay all failed deliveries
GET/api/v1/stats/summaryDelivery statistics
GET/healthDatabase connectivity check

Quick Example

quickstart_api.sh
cURL / Bash
# Register a tenant
curl -s -X POST http://localhost:8080/api/v1/applications \
  -H "Content-Type: application/json" \
  -d '{"name": "My App"}' | jq .

# Create an event type
curl -s -X POST http://localhost:8080/api/v1/event-types \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "order.completed", "description": "Order fulfilled"}'

# Register an endpoint
curl -s -X POST http://localhost:8080/api/v1/endpoints \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://webhook.site/your-uuid", "description": "Partner CRM", "rate_limit": 10}'

# Subscribe endpoint to event type
curl -s -X POST http://localhost:8080/api/v1/endpoints/$ENDPOINT_ID/subscriptions \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type_id": "'$EVENT_TYPE_ID'"}'

# Publish an event
curl -s -X POST http://localhost:8080/api/v1/events \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "order.completed",
    "idempotency_key": "order_12345",
    "payload": {"order_id": "order_12345", "total": 149.99}
  }'
# 202 Accepted. Delivery tasks are queued and processing.

Project Structure

hookops / architecture_tree
Tree
hookops/
├── cmd/api/main.go              # Entrypoint, dependency wiring, graceful shutdown
├── internal/
│   ├── config/                  # Env-based configuration with validation
│   ├── database/                # pgxpool connection factory
│   ├── dto/                     # Request/response data transfer objects
│   ├── engine/                  # Core delivery engine
│   │   ├── deliverer.go         # HTTP POST with timeout and body caps
│   │   ├── dispatcher.go        # Background poller with adaptive drain
│   │   ├── worker_pool.go       # Bounded goroutine pool with backpressure
│   │   ├── recorder.go          # Outcome recording and circuit breaker updates
│   │   ├── retry.go             # Stepped backoff with jitter
│   │   ├── limiter.go           # Per-endpoint token bucket rate limiter
│   │   └── signer.go            # HMAC-SHA256 signing and verification
│   ├── handler/                 # HTTP handlers (Gin)
│   ├── middleware/              # Auth, CORS, logging, panic recovery
│   ├── models/                  # Domain types
│   ├── repository/              # PostgreSQL data access layer
│   ├── response/                # Standardized JSON response helpers
│   ├── router/                  # Route registration and middleware wiring
│   ├── server/                  # HTTP server lifecycle
│   └── service/                 # Business logic layer
├── migrations/                  # 8 sequential SQL migrations (up + down)
├── tests/integration/           # End-to-end tests against real PostgreSQL
├── docs/                        # Auto-generated OpenAPI spec
├── Dockerfile                   # Multi-stage build (golang:alpine > alpine:3.20)
├── docker-compose.yml           # One-command local setup
└── Makefile                     # Build, test, lint, migrate, swagger targets

Running Locally

terminal — run_local.sh
Docker & Make
# Start PostgreSQL and HookOps
docker compose up -d

# Verify
curl http://localhost:8080/health
# {"status":"healthy","service":"hookops","database":"connected"}

# Or run outside Docker
cp .env.example .env
make migrate-up
make run

Configuration

All settings via environment variables:

VariableDefaultDescription
SERVER_PORT8080HTTP listen port
GIN_MODEdebugdebug or release
DB_HOSTlocalhostPostgreSQL host
DB_PORT5433PostgreSQL port
DB_USERpostgresDatabase user
DB_PASSWORDpostgresDatabase password
DB_NAMEhookopsDatabase name
DB_SSL_MODEdisabledisable or require
WORKER_COUNT5Delivery worker goroutines
QUEUE_SIZE100Buffered channel capacity
DISPATCHER_POLL_INTERVAL2sPolling interval for pending tasks
DISPATCHER_BATCH_SIZE50Tasks claimed per poll cycle
MAX_RETRIES5Attempts before dead-lettering
RETRY_BASE_DELAY30sFirst retry delay
RETRY_MAX_DELAY4hBackoff ceiling
CIRCUIT_BREAKER_MAX_FAILURES20Consecutive failures to auto-disable endpoint

Testing

~6,000 lines of production code, ~6,000 lines of test code across 29 test files.

test_runner — suite
Go Test
make test              # Unit tests
make test-integration  # End-to-end against real PostgreSQL
make test-coverage     # Coverage report with HTML output
make lint              # golangci-lint, zero warnings

Unit tests use hand-written interface mocks (no codegen) for the repository and service layers.

Integration tests run against a real PostgreSQL instance and cover the full lifecycle: event ingestion, fan-out, delivery attempt creation, idempotency enforcement, and retry state transitions.

Database

8 migrations applied in order:

  1. applications - Tenant accounts with auto-generated ho_live_... API keys
  2. endpoints - Webhook destination URLs with unique signing secrets (whsec_...)
  3. event_types - Named event categories (e.g. invoice.paid)
  4. subscriptions - Many-to-many link between endpoints and event types
  5. events - Ingested payloads with idempotency keys
  6. delivery_attempts - The queue. Status lifecycle: pending > processing > delivered | failed | dead_letter
  7. rate_limit - Per-endpoint RPS configuration
  8. health_tracking - Consecutive failure counter for circuit breaker logic

The delivery_attempts table doubles as a job queue using FOR UPDATE SKIP LOCKED, which lets PostgreSQL handle locking semantics instead of requiring a separate message broker.

Design Decisions

PostgreSQL as the queue.

SKIP LOCKED eliminates an entire infrastructure dependency (no Redis, RabbitMQ, or Kafka needed) while providing exactly-once claim semantics within a single transaction. The tradeoff is throughput ceiling, but for webhook delivery volumes (hundreds to low thousands per second), it's more than sufficient and operationally much simpler.

Stepped multipliers over pure exponential.

Instead of baseDelay * 2^attempt, the retry schedule uses hand-tuned multipliers (1x, 4x, 20x, 120x, 480x). This gives a more practical progression from 30 seconds to 4 hours that aligns with real-world endpoint recovery patterns.

Token bucket rate limiting.

golang.org/x/time/rate implements token bucket, which allows short bursts up to the bucket size while maintaining an average rate. The limiter registry uses sync.Map with LoadOrStore for zero-allocation reads on the hot path, and dynamically adjusts if a customer updates their endpoint's rate limit via the API.

Response body capping.

The deliverer reads at most 4 KB of response body via io.LimitReader. This prevents memory exhaustion from endpoints that return large HTML error pages.

License

MIT

Tools and Technologies

#Go#PostgreSQL#Docker#REST API#Gin#Distributed Systems#OpenAPI