Webhooks

Register a URL and Catentio POSTs to it when your runs and projects finish, so you don't have to poll.

Not to be confused with webhook gates. Those are inbound: a pipeline step blocks until an external system calls in to release it. These are the other direction — Catentio calling out to you.

The events

Three. That is the whole list.

Event Fires when
run.succeeded An agent run finished successfully.
run.failed An agent run failed.
project.completed A pipeline project finished.

Each one corresponds to a state transition that exists in the code. There is no event for anything else, and an unknown event type is rejected at registration with a 400 rather than silently matching nothing — a typo that never fires is a webhook you spend a day debugging.

Register an endpoint

curl -X POST https://catent.io/v1/webhooks \
  -H "Authorization: Bearer cat_pk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/hooks/catentio",
    "events": ["run.succeeded", "run.failed"],
    "description": "prod"
  }'
{
  "id": 1,
  "url": "https://your-app.example.com/hooks/catentio",
  "events": ["run.succeeded", "run.failed"],
  "active": true,
  "secret": "whsec_2f1c..."
}

events defaults to ["*"] (everything).

The secret is returned once and never again. GET /v1/webhooks does not echo it — if it did, any read of that endpoint would be a credential leak. Store it when you create the endpoint.

The URL must be https://. Deliveries carry your run's output, which is your content; the signature proves who sent it, it does not hide it.

The delivery

POST /hooks/catentio HTTP/1.1
Content-Type: application/json
X-Catentio-Event: run.succeeded
X-Catentio-Signature: t=1783834680,v1=692dca4f1c982c05...

{"type":"run.succeeded","created_at":1783834680,"data":{"run_id":"agp...","agent":"fumi","status":"succeeded","output":"..."}}

Verifying the signature

v1 = HMAC-SHA256(secret, "<t>.<raw request body>")

This is the same scheme Stripe and Plugipay use, so you may already have code for it.

import hashlib, hmac, time

def verify(secret: str, header: str, raw_body: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance_s:
        return False                       # stale / replayed
    expected = hmac.new(
        secret.encode(), f"{ts}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Two things that will bite you if you skip them:

  • Sign the raw bytes you received, not a re-serialised copy. Parsing the JSON and dumping it again changes key order and separators, the digest changes, and every delivery fails verification for reasons that are invisible in the logs.
  • Check t. The timestamp is inside the signed string so that an old-but-valid delivery cannot be replayed at you forever. Reject anything older than a few minutes.

Retries

A delivery is retried on a timeout, a 5xx, or a 429: backoff doubles from 30 seconds, caps at an hour, and gives up after 8 attempts (roughly a day).

A 4xx other than 429 is not retried. That is your endpoint saying it has rejected the request, and retrying it eight times would just be an attack on your own server.

Return 2xx fast. Do the work afterwards. If you process the event inline and take 20 seconds, we time out at 10 and retry — and you process it twice.

Two things we do not promise

Ordering. A retried delivery will arrive after a later event. Use the ids in the payload; don't infer sequence from arrival.

Exactly-once. A delivery can arrive twice — if your 2xx is lost on the way back, we retry a request you already handled. Make your handler idempotent, keyed on data.run_id or data.project_id. This is true of every webhook system that is honest about it.

Manage endpoints

# list (no secrets)
curl https://catent.io/v1/webhooks -H "Authorization: Bearer cat_pk_..."

# delete — queued deliveries for it are dropped
curl -X DELETE https://catent.io/v1/webhooks/1 -H "Authorization: Bearer cat_pk_..."