Ankusa

Ankusa

Docker pulls CI Hex version License: Apache 2.0

Don't fight the traffic. Steer it.

Ankusa is a self-hosted webhook receiver. Point Stripe, GitHub, or any provider at it: every hook is written to a durable log before Ankusa answers 2xx, every accepted POST is stored under a fresh id, and each hook is delivered to your own worker over HTTP, RabbitMQ, Kafka, or NATS JetStream, with retries, a dead-letter queue, and replay. Start with one container. Grow into a fleet by changing config, not code.

Quickstart

1. Run it

docker run -d --name ankusa \
  -p 4000:4000 -p 127.0.0.1:4002:4002 \
  -v ankusa-data:/var/lib/ankusa \
  jamescarr/ankusa:edge

# the image ships a healthcheck: wait for it rather than racing the listener
until [ "$(docker inspect --format '{{.State.Health.Status}}' ankusa)" = healthy ]; do sleep 1; done

curl -XPOST localhost:4000/webhooks/demo -H 'content-type: application/json' -d '{"id":"evt_1"}'
# => {"id":"01a0...","status":"accepted","seq":1}   (returned only after the WAL fsync)

Done? docker rm -f ankusa

That is a complete, durable webhook receiver. The image ships a demo source that accepts anything, so it works before you have any provider credentials: 4000 is ingest, 4002 is the operator API (/health, /metrics), and /var/lib/ankusa holds the only state worth keeping.

2. Deliver to your own worker

Ankusa POSTs each hook to your endpoint with the raw body intact and x-ankusa-id for idempotency; short outages are retried, long ones land in a dead-letter queue you can replay with one call.

git clone https://github.com/jamescarr/ankusa
cd ankusa/examples/quickstart
docker compose up -d --wait
curl -XPOST localhost:4000/webhooks/demo -H 'content-type: application/json' -d '{"id":"evt_1","type":"invoice.paid"}'
sleep 1 && docker compose logs worker
# received id=01a0... source=demo seq=1 bytes=36 body={"id":"evt_1","type":"invoice.paid"}

The wiring is one sink in ankusa.yml:

sources:
  demo:
    verify: {type: none}
    on_verify_failure: accept_flag
    sinks:
      - type: http
        url: http://worker:8080/hooks
        timeout_ms: 5000

The worker is 40 lines of standard-library Python; replace it with your own service. The quickstart guide walks through outages, dead letters, replay, and pointing a real provider at it.

How it works

Ankusa writes every hook to a durable log before it answers 2xx: the fsync lands first, and 201 accepted is the only committed response. There is no 200. If a node dies before the write, the provider never got an ack and retries; that retry is a new hook with a new id, stored and delivered again, because ingest does no deduplication and delivery is at-least-once. When storage slows down, Ankusa answers 503 with Retry-After, so providers back off and try again instead of losing events. You get that guarantee on day one, on one machine, and you keep it when you run a hundred.

docs/architecture.md walks the full pipeline and shows why each guarantee holds.

flowchart LR
    P[Provider] -->|POST catch URL| E[Edge]
    E --> B[Group-commit Batcher]
    B -->|one fsync| W[(WAL)]
    W -->|ack| P
    W --> C[Compactor]
    W --> D[Dispatch]
    C --> S[(Object store)]
    D --> SK[Sinks]

What you get

Every piece is a swappable module, so when your needs outgrow a default you replace that piece and keep the rest.

Grow into a fleet

When one box is not enough, run N independent all-role nodes behind a load balancer. Each node has its own data volume, its own WAL, and its own DLQ/admin API. Archive to S3 or GCS, and fan out to Kafka, NATS, or RabbitMQ. WAL.DiskLog keeps edge, dispatch, and storage in one BEAM node, so a node is the unit of scale: add nodes, not roles. Give each node its own bucket (or LocalFS) for segments. Segment keys are seg/<first_seq>-<last_seq>.seg, and remote blob stores ignore the instance, so nodes sharing a bucket overwrite each other's segments.

flowchart LR
    P[Stripe, GitHub, ...] --> LB[Load balancer]
    LB --> E1[Ankusa node 1]
    LB --> E2[Ankusa node 2]
    LB --> E3[Ankusa node N]
    E1 --> WAL1[("own WAL\n+ volume")]
    E2 --> WAL2[("own WAL\n+ volume")]
    E3 --> WAL3[("own WAL\n+ volume")]
    WAL1 & WAL2 & WAL3 --> Q[Kafka / NATS / RabbitMQ / HTTP]
    WAL1 & WAL2 & WAL3 --> S[(S3 / GCS)]
    Q --> W[Your workers]

Your workers can be written in anything. A TypeScript, Python, or Go consumer reads from the queue and fetches large payloads over HTTP without ever holding storage credentials.

Roles, the container, and fleets: docs/deployment.md.

Examples

Example Delivers via Worker Shows
quickstart HTTP Python retries, dead letters, replay
rabbitmq-consumer RabbitMQ TypeScript consumer-owned queues, large payloads via the claim-check gateway
kafka-sqs-consumer Kafka → SQS FIFO TypeScript per-source ordering, failure drills
oban-consumer HTTP → Oban on Kubernetes Elixir a fleet of independent nodes, each with its own disk WAL, load-tested with pods killed mid-run

How to pick one: examples/README.md

Documentation

Ankusa is also an Elixir library you can embed in your own app: docs/elixir.md · HexDocs.

The name

अंकुश (aṅkuśa) is Sanskrit for "hook" or "goad": the curved tool a mahout uses to steer an elephant. Its root, aṅka, means "to bend" or "curve".

Webhook traffic behaves like the elephant. It is large, it arrives on its own schedule, and it will not wait for you. So the framework takes the same shape: absorb the traffic durably and fast through the WAL and batcher, then steer where it goes next through routing and dispatch.

License

Apache 2.0. See LICENSE.