Webhooks
Domain approval, signature verification, the retry ladder, and auto-deactivation.
Subscribe an HTTPS endpoint and we will POST events to it as they happen.
Create one
curl -X POST "$BASE/api/crm/$CRM/webhooks" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/theonlyapi",
"event_types": ["new_subscriber", "new_tip"],
"description": "production worker"
}'| Field | Required | Notes |
|---|---|---|
url | yes | http:// is accepted, but see the HTTPS note below |
event_types | yes | Must be a non-empty array. Use ["*"] for every type |
description | no | Free-text label, max 200 characters |
It is `event_types`, and it cannot be empty
The field is event_types — not events. A body using events is silently
ignored, which then fails the non-empty check and returns
400 event_types must be a non-empty list.
There is also no of_user_id field and no enabled field on create. See
below.
The response includes the generated signing secret — store it, it is what you
verify deliveries with. It is also returned by GET /webhooks and
GET /webhooks/{id}.
Approval — a new webhook may deliver nothing
This is the first thing to check when a webhook seems dead.
A webhook is created with a status of either approved or pending, returned
in the create response. A pending webhook receives no deliveries at all.
These hosts are approved immediately:
discord.com discordapp.com hooks.slack.com api.telegram.orgSo is any host an admin has already approved for your panel. Every other host starts as pending — including your own domain, the first time.
| Symptom | Cause |
|---|---|
POST /webhooks/{id}/test returns 403 Webhook is awaiting admin approval and cannot send yet | status: "pending" |
Events appear in GET /events but never arrive at your URL | status: "pending" |
status: "rejected" with a reject_reason | An admin declined the domain |
Check status on GET /webhooks/{id}. If it is pending,
contact support to get the domain reviewed.
Once a host is approved for your panel, further webhooks on that same host are approved automatically. Repointing a webhook at a different host puts it back into review; changing only the path does not.
Webhooks are panel-wide
Every connected account's events go to every matching webhook. There is no
per-webhook account filter — the webhooks table has no of_user_id column.
If you need per-account routing, either filter on of_user_id in your handler,
or use an automation, which does accept of_user_id.
Delivery format
Every delivery is a POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | TheOnlyAPI-Webhook/1.0 |
X-OnlyAPI-Signature | sha256=<hex digest> |
X-OnlyAPI-Timestamp | Unix seconds — the same value that was signed |
X-OnlyAPI-Event | The event type, e.g. new_tip |
X-OnlyAPI-Delivery-Id | Unique per attempt |
The body is exactly six keys — id, event_type, crm_id, of_user_id,
occurred_at, payload. (The SSE stream and GET /events add source_event_id
and created_at; the webhook body is trimmed to these six.) See
Events for payload shapes.
Verifying the signature
The signed message is not the body alone. It is the timestamp, a literal .,
then the raw request body:
message = "{X-OnlyAPI-Timestamp}." + raw_body
signature = "sha256=" + HMAC_SHA256(webhook_secret, message).hexdigest()Sign the raw bytes, before any JSON parsing
We serialise with compact separators (, and :, no spaces). Re-serialising
parsed JSON will not reproduce those bytes and the signature will not match.
Capture the body as bytes first, verify, then parse.
import hmac, hashlib
def verify(secret: str, timestamp: str, raw_body: bytes, received: str) -> bool:
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Flask
@app.post("/hooks/theonlyapi")
def hook():
raw = request.get_data() # bytes, before parsing
ok = verify(SECRET,
request.headers["X-OnlyAPI-Timestamp"],
raw,
request.headers["X-OnlyAPI-Signature"])
if not ok:
return "", 401
event = request.get_json()
...
return "", 200Always use a constant-time comparison (hmac.compare_digest,
crypto.timingSafeEqual, hmac.Equal) rather than ==.
Retries and auto-deactivation
A delivery is retried on this ladder, up to 6 attempts total:
5s → 30s → 5m → 30m → 2hThose are lower bounds — a scheduler job sweeps due retries every 10 seconds and processes at most 50 per pass.
A delivery only counts as a failure once it has exhausted the whole ladder, or hit a non-retryable error. After five such consecutive failures the webhook is set inactive and stays that way until you re-enable it:
curl -X PATCH "$BASE/api/crm/$CRM/webhooks/$WEBHOOK_ID" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"is_active": true}'Setting is_active: true also resets the failure counter.
Five exhausted ladders is over half a day
Because each failure must walk the full ~2h ladder first, auto-deactivation takes
well over half a day of sustained downtime — but it also means a webhook can be
quietly failing for hours before anything gives up. Watch
GET /webhooks/{id}/deliveries?limit=50 (max 200), or reconcile against
GET /events.
Retried rows appear in the log with status superseded — that is bookkeeping, not
a failure.
Non-retryable failures
| Response | Behaviour |
|---|---|
3xx | Terminal, never retried. We send with redirects disabled and refuse to carry a signed payload to a Location we did not validate (redirect refused) |
| Private / loopback target | Blocked before the request is made — logged as blocked: <reason> |
| Any other non-2xx | Retried on the ladder |
Writing a good handler
- Return 2xx fast, and never 3xx. Acknowledge, enqueue, process asynchronously.
- Be idempotent. Retries mean the same event can arrive more than once. Dedupe
on the event
id. - Verify before you trust. The URL is public; the signature is what makes the payload yours.
- Use HTTPS.
http://is accepted, but the payload carries fan PII and the signature authenticates it without encrypting it.
Testing locally
curl -X POST "$BASE/api/crm/$CRM/webhooks/$WEBHOOK_ID/test" -H "X-API-Key: $KEY"Fires a real, signed delivery so you can validate the whole path including
signature verification. It returns 403 if the webhook is still pending.
A public tunnel is required, not optional
Deliveries to localhost, 127.0.0.1 and any private or link-local address are
blocked before the request is made, and DNS failures fail closed. Use ngrok or
cloudflared while developing — and remember the tunnel host will start as
pending unless it has been approved for your panel.
Routes
GET /webhooks # list, each with its secret and status
POST /webhooks # create
GET /webhooks/{webhook_id} # read one
PATCH /webhooks/{webhook_id} # url, event_types, description, is_active
DELETE /webhooks/{webhook_id}
POST /webhooks/{webhook_id}/test # fire a real signed delivery
GET /webhooks/{webhook_id}/deliveries?limit=50 # delivery log, max 200