The Only API docs

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"
      }'
FieldRequiredNotes
urlyeshttp:// is accepted, but see the HTTPS note below
event_typesyesMust be a non-empty array. Use ["*"] for every type
descriptionnoFree-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.org

So is any host an admin has already approved for your panel. Every other host starts as pending — including your own domain, the first time.

SymptomCause
POST /webhooks/{id}/test returns 403 Webhook is awaiting admin approval and cannot send yetstatus: "pending"
Events appear in GET /events but never arrive at your URLstatus: "pending"
status: "rejected" with a reject_reasonAn 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:

HeaderValue
Content-Typeapplication/json
User-AgentTheOnlyAPI-Webhook/1.0
X-OnlyAPI-Signaturesha256=<hex digest>
X-OnlyAPI-TimestampUnix seconds — the same value that was signed
X-OnlyAPI-EventThe event type, e.g. new_tip
X-OnlyAPI-Delivery-IdUnique 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 "", 200

Always 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  →  2h

Those 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

ResponseBehaviour
3xxTerminal, 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 targetBlocked before the request is made — logged as blocked: <reason>
Any other non-2xxRetried 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

On this page