REST API • Beta

Webhook API
for cross-posting

One HTTP request — post publishes to Telegram, VK and Max simultaneously. Bearer token, JSON, Idempotency-Key. No OAuth, simple payload format.

Webhook input — Pro+. Sandbox — all plans. Webhook output — Standard+.

Single endpoint

Instead of three APIs (Telegram Bot, VK, Max) — one webhook URL. Crosslybot adapts payload for each platform automatically.

LLM-friendly

Bearer token. ChatGPT, Claude, custom AI agents connect in minutes — standard REST pattern.

Secure

Bearer token, Idempotency-Key, HMAC-signed outbound webhooks, request history. Production-grade security.

Minimal example in 5 minutes

Sign up → create webhook → cURL command. Full guide: /webhook/quickstart.

terminal
curl -X POST 'https://wh.crosslybot.com/v1/webhooks/8qwv9XeTcR6Nyn2nlIW_wQ' \
  -H 'Authorization: Bearer crossly_live_xxxxxxxxxxxxxxxxxxxx' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: hello-world-001' \
  -d '{
    "text": "Hello, world! 👋",
    "media": [
      {"type": "photo", "url": "https://picsum.photos/800"}
    ],
    "buttons": [[
      {"text": "Learn more", "url": "https://crosslybot.com"}
    ]]
  }'
Success response
200 OK · {"ok": true, "id": "post_..."}
Sandbox without publishing
POST /v1/sandbox/test

Use cases

6 detailed guides covering main scenarios. From quick-start cURL to AI agents and no-code workflows.

API Reference

API Documentation

Full spec: payload schema, headers, responses, retries, limits. Enough for production integration.

Formatting — Telegram Bot API entities format

Inbound webhook (IN) accepts text formatting in Telegram entities format: offset, length, type — same values, same semantics. All 15+ types supported: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, expandable_blockquote, text_link, mention, url, email, phone_number, hashtag, custom_emoji.

If your code already works with the Telegram Bot API — reuse the entities serialization as-is. The message structure (media, albums, buttons) is our own, unified across platforms — simpler than TG's. Full diff table below ↓

Outbound webhook (OUT) delivers payload in the same format as IN accepts — text (plain) + entities[] (TG-style array, UTF-16 offsets/length). Symmetric: one format for sending and receiving. If you need HTML — convert entities to tags on your side (standard helpers in TG SDKs).

Basics

  • Base URL: https://wh.crosslybot.com
  • Method: POST
  • Path: /v1/webhooks/{slug}
  • Authentication: Bearer token in Authorization header
  • Content-Type: application/json
  • Encoding: UTF-8
  • Request size: up to 5 MB

HTTP request headers

Header Required Description
AuthorizationyesBearer crossly_live_… or crossly_test_… Plaintext value is shown once on creation — save it immediately.
Content-Typeyesapplication/json
Idempotency-KeynoUUID or unique string. Repeated POST with same key within 24h returns cached response. Up to 128 chars.
User-AgentnoRecommended: client name (e.g. my-ai-agent/1.0) — shows up in request history

JSON payload fields

Minimal valid post: must include at least one of text, media[], or buttons[].

Field Type Description
textstringPost text. IN: plain text without HTML tags. OUT: plain (HTML tags stripped). Limit — most generous of platforms (15895 for VK).
entitiesarrayFormatting in Telegram Bot API format: bold, italic, underline, strikethrough, spoiler, code, pre, blockquote, text_link, mention. Offsets/length in UTF-16 code units. Identical format in IN (what we accept) and OUT (what we deliver) — clients learn the format once.
media[]arrayMedia: up to 10 items. Each — { type: photo|video|audio, url: https://… }. document — rejected (400). Album = media array in one POST (unlike TG Bot API, where albums arrive as N updates with the same media_group_id). No buffering needed — published as a single media group.
buttons[][]arrayURL buttons: rows × buttons (up to 8×8). Each — { text, url }. URL must start with https:// or tg://. Telegram and Max publish as inline buttons. VK wall doesn't support inline buttons — embed the URL inside the text or use a link signature for VK.
is_advertisementboolAd post marker. Not required for pause logic — used as a label for analytics
ad_pause_minutesint0–1440. Pauses the whole project after the first successful publication of this post (applied once). Already accepted posts complete; new posts wait until the pause expires
ad_target_pause_minutesint0–1440. Global channel pause for published targets — applies across all your projects with those channels. Applied on successful publication. Can be combined with ad_pause_minutes
external_idstringAlternative to Idempotency-Key (can be used together). Up to 128 chars
trace_idstringCustom ID for end-to-end debugging. Returned in response and logs
metadataobjectCustom fields. Stored in Post.extra_data

Media limits & validation

  • 📷 photo: up to 20 MB (jpg/png/webp)
  • 🎬 video: up to 2 GB, 30 min (mp4/mov/webm)
  • 🎵 audio: up to 500 MB (mp3/m4a/ogg)
  • 📑 document: rejected (400)
  • 📦 Total media[]: up to 4 GB
  • 🔢 Items count: up to 10
  • 🌐 URL: https:// only, ≤ 2048 chars, direct file link without redirects
  • 🛡 Decompression bomb: ≤ 25 MP for photos
What gets validated: https:// only, media type from a whitelist (photo/video/audio), real size and mime-type, container correctness. If a URL/file fails validation — response is 400 with details in errors[]. Documents and private addresses are rejected.

⚠ Redirects not allowed — provide a direct file link. If your CDN/hosting returns a redirect — publishing fails.

HTTP response codes

Code Meaning Client action
200Accepted, queuedDone
202Already accepted (Idempotency)Same result — no action
400Invalid payloadRead errors[] and fix
401Invalid or revoked BearerRotate token
402Plan doesn't allowUpgrade plan / buy posts pack
403Endpoint deactivatedReactivate in UI
404endpoint_id not foundCheck URL
422Invalid JSON schemaRead errors[] in body — per-field details there
429Rate limit exceededHonor Retry-After header
503Webhook API temporarily unavailableRetry with backoff
5xxInternal errorRetry with exponential backoff

Decisions in "Request history"

The endpoint card UI shows a journal of last 100 requests. Each record has decision — the internal reason. Helps debug your integration when client only sees plain 401 or 429:

Decision HTTP What happened
accepted200Accepted, post created and queued for publishing
accepted_no_project200Accepted, but webhook is not bound to any project — post is not created (silent)
accepted_idempotent_replay200Duplicate by Idempotency-Key, cached response returned
rejected_auth401Slug not found, token mismatch, IP outside allowlist, signature failed or timestamp out of window — client gets identical response, exact reason is in journal
rejected_validation400/415/422Invalid JSON, wrong Content-Type, payload >5MB or schema validation failed (details in errors[])
rejected_rate_limit429One of the limits exceeded: min-interval, burst, or hourly quota. Honor Retry-After
rejected_paused503Endpoint is disabled (manually or auto-paused after rate-limit violations)
rejected_tier503Current plan no longer allows webhook IN — endpoint auto-disabled
rejected_disabled503Webhook API temporarily unavailable (maintenance)

Code examples

Replace 12345 with your endpoint_id and crossly_live_... with your token.

cURL
curl -X POST https://wh.crosslybot.com/v1/webhooks/8qwv9XeTcR6Nyn2nlIW_wQ \
  -H "Authorization: Bearer crossly_live_xxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "text": "Hello, world!",
    "entities": [{"type": "bold", "offset": 0, "length": 5}],
    "media": [
      {"type": "photo", "url": "https://cdn.example.com/img.jpg"}
    ],
    "external_id": "post-123",
    "metadata": {"source": "ai-agent"}
  }'
Python (requests + retry)
import os, time, uuid, requests
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

session = requests.Session()
session.mount("https://", HTTPAdapter(max_retries=Retry(
    total=4,
    backoff_factor=2,            # 2s / 4s / 8s / 16s
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["POST"],
    respect_retry_after_header=True,
)))

SLUG = "8qwv9XeTcR6Nyn2nlIW_wQ"
TOKEN = os.environ["CROSSLY_TOKEN"]

resp = session.post(
    f"https://wh.crosslybot.com/v1/webhooks/{SLUG}",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
        "User-Agent": "my-ai-agent/1.0",
    },
    json={
        "text": "Hello, world!",
        "entities": [{"type": "bold", "offset": 0, "length": 5}],
        "media": [{"type": "photo", "url": "https://cdn.example.com/img.jpg"}],
        "external_id": "post-123",
    },
    timeout=30,
)
resp.raise_for_status()
print(resp.json())
Node.js (fetch + retry)
import { randomUUID } from "crypto";

const SLUG = "8qwv9XeTcR6Nyn2nlIW_wQ";
const TOKEN = process.env.CROSSLY_TOKEN;
const BACKOFFS = [2000, 4000, 8000, 16000]; // 2s / 4s / 8s / 16s

async function publish(payload) {
  for (let attempt = 0; attempt < BACKOFFS.length + 1; attempt++) {
    const resp = await fetch(
      `https://wh.crosslybot.com/v1/webhooks/${SLUG}`,
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${TOKEN}`,
          "Content-Type": "application/json",
          "Idempotency-Key": randomUUID(),
          "User-Agent": "my-ai-agent/1.0",
        },
        body: JSON.stringify(payload),
      }
    );
    if (resp.ok) return resp.json();
    if (![429, 500, 502, 503, 504].includes(resp.status)) {
      throw new Error(`Crosslybot ${resp.status}: ${await resp.text()}`);
    }
    if (attempt === BACKOFFS.length) throw new Error("Max retries exceeded");
    const ra = Number(resp.headers.get("retry-after")) * 1000;
    await new Promise(r => setTimeout(r, ra || BACKOFFS[attempt]));
  }
}

await publish({
  text: "Hello, world!",
  media: [{ type: "photo", url: "https://cdn.example.com/img.jpg" }],
  external_id: "post-123",
});

Sandbox: testing without publishing

Sandbox is a separate endpoint for validating payload format and media without DB writes and without publishing to channels. Response shape matches production: you see what errors would be raised, which targets would receive the post, and how text trimming would apply per platform.

  • URL: POST /v1/sandbox/test
  • Auth: same Bearer token (live or test)
  • Payload: identical to production
  • No charges against post quota, not shown in publish history
  • Validates: JSON schema, entities, length limits, https://, media type and real mime-type
  • Does NOT validate: actual delivery to Telegram/VK/Max API (local validation only)
  • Free limits: 100 req/day, Mini: 1,000/day, Standard+: ∞
Sandbox request (cURL)
curl -X POST https://wh.crosslybot.com/v1/sandbox/test \\
  -H "Authorization: Bearer crossly_test_xxxxxxxxxxxxxxxxxxxx" \\
  -H "Content-Type: application/json" \\
  -d '{
    "text": "Test post",
    "entities": [{"type": "bold", "offset": 0, "length": 4}],
    "media": [
      {"type": "photo", "url": "https://cdn.example.com/img.jpg"}
    ]
  }'
Successful response example (200)
{
  "ok": true,
  "would_create_post": true,
  "validation": {
    "schema": "passed",
    "media": [{"url": "https://cdn.example.com/img.jpg", "size_mb": 2.4, "ok": true}],
    "entities": "passed"
  },
  "preview": {
    "telegram": {"text_after_trim": "Test post", "trimmed_chars": 0},
    "vk":       {"text_after_trim": "Test post", "trimmed_chars": 0},
    "max":      {"text_after_trim": "Test post", "trimmed_chars": 0}
  }
}

💡 Use sandbox in CI/CD as a smoke test after each integration deploy — a free way to ensure your payload generator isn't broken.

Idempotency

The Idempotency-Key header or external_id field — standard way to safely retry requests.

  • • Keys are cached for 24 hours per endpoint
  • • Repeated POST with the same key returns 202 with cached response — NO duplicate post created
  • • If the first request 5xx-failed and wasn't processed — retry creates the post fresh
  • • Key length up to 128 chars. Recommended: UUIDv4 (universal) or business ID (stable for logic)

Compatibility with Telegram Bot API

The payload format is intentionally close to Telegram Bot API — if you already have TG code, much of it can be reused. There are some model differences though, listed below.

Aspect TG Bot API Crosslybot
Formatting (entities) type/offset/length/url/language/custom_emoji_id, offsets in UTF-16 ✓ Same format
Album N separate updates with shared media_group_id One POST with media[] array (atomic, no buffering)
Text vs caption Separate: text for messages, caption for media Single text — used as caption when media[] is present
Media file_id or different structures (photo[], video, document) {type, url} — unified structure for photo/video/audio
Buttons inline_keyboard with callback, switch_inline, etc. URL buttons only {text, url} (callback isn't portable to VK/Max). Published on TG and Max; VK wall doesn't support buttons — ignored.
Idempotency update_id in payload Idempotency-Key header or external_id field (24h cache)
Polls Supported Not supported (polls aren't uniform across VK/Max)
Documents Any files Rejected (400) — security and cross-platform compatibility

💡 Migrating from TG Bot API: the main change is bundling album items into a single POST with media[] instead of sequential sends. Entities and URL buttons are reused as-is.

Targeted publishing & auto-pauses

Three independent payload-level controls — combine them freely.

1. Targeted publishing — targets[]

By default, the post is published to all active targets of the project. To restrict — pass targets[] with target public_ids (copied from the target card).

{
  "text": "Announcement — only to main channels",
  "targets": [
    { "id": "tgt_aBcDeFgHiJkLmNoPqRsTuV" },
    { "id": "tgt_xYz123456789abcdefGhi" }
  ]
}

⚠️ If none of the provided ids match active targets in the project — the response is 422 invalid_targets, no post is created.

2. Project auto-pause — ad_pause_minutes

After the first successful publication, the entire project pauses for N minutes. Applied once — multi-target post-targets of the same post don't extend the pause.

{
  "text": "Sponsored block",
  "is_advertisement": true,
  "ad_pause_minutes": 60
}

3. Channel auto-pause — ad_target_pause_minutes

Each successfully published channel gets a global pause — it applies across all your projects that use this channel. Useful when an ad-network contract caps you at "no more than one ad per hour per channel": regular cross-posting into the same channel waits too.

{
  "text": "Sponsored — TG channel cool-down 24h",
  "is_advertisement": true,
  "ad_target_pause_minutes": 1440,
  "targets": [
    { "id": "tgt_aBcDeFgHiJkLmNoPqRsTuV" }
  ]
}
  • ad_pause_minutes and ad_target_pause_minutes are independent — use one, both, or neither.
  • • Trigger is actual successful publication. If all targets fail — no pause is applied.
  • • Channel pause is global: it applies across all your projects using that channel. Parallel regular cross-posting into it waits too.
  • • Existing manual pauses are never shortened. Auto-pause only extends if the new value is later.
  • is_advertisement is an analytics label — not required for the pause logic to apply.

Pause without publishing — temp-pause / temp-resume

The auto-pauses above fire together with publishing a post. If you need to set a pause separately (e.g. an ad network wants to "book silence" in advance) — there are two endpoints that publish nothing, just manage the pause.

POST /v1/webhooks/{slug}/temp-pause

{
  "project_minutes": 60,        // pause the whole project (optional)
  "target_minutes": 120,        // global pause for channels (optional)
  "targets": ["tgt_aBc..."]     // which channels; empty = all project targets
}
  • • At least one of project_minutes / target_minutes must be > 0.
  • target_minutes — global resource pause: applies across all your projects with that channel.
  • • If targets is given but no id belongs to this webhook's projects — 422 invalid_targets.
  • • Idempotent: never shortens an already-active longer pause.

POST /v1/webhooks/{slug}/temp-resume

{
  "scope": "all",               // "project" | "targets" | "all" (default all)
  "targets": ["tgt_aBc..."]     // which channels to resume (if scope=targets)
}

Posts accumulated during the pause are published at 1-min intervals after resume. Both endpoints use the same auth stack (Bearer + optional HMAC + timestamp) as publishing.

Response: {ok, request_id, projects_affected, resources_affected, rescheduled_post_targets}.

Discovery endpoint — for MCP & automations

So MCP servers (Claude Desktop, Cursor) and scripts don't need to copy tgt_… for every target manually — a single request returns the whole structure:

curl https://wh.crosslybot.com/v1/webhooks/{slug}/info \
  -H "Authorization: Bearer crossly_live_..."

Default (anonymized) response:

{
  "endpoint": { "slug": "...", "name": null, "verbose": false },
  "projects": [
    {
      "index": 1,
      "name": null,
      "paused_until": null,
      "targets": [
        { "id": "tgt_aBc...", "platform": "telegram", "name": null, "paused_until": null },
        { "id": "tgt_xYz...", "platform": "max", "name": null, "paused_until": null }
      ]
    }
  ],
  "capabilities": {
    "entity_types": ["bold","italic","spoiler", ...],
    "media_types": ["photo","video","audio"],
    "limits": {
      "max_text_length": 15895, "max_media": 10,
      "max_buttons_rows": 8, "max_buttons_per_row": 8,
      "max_payload_bytes": 5242880, "max_pause_minutes": 1440
    }
  }
}

Verbose mode — enabled in the webhook IN settings (Security → toggle "Reveal names in discovery (/info)"). Then null values are replaced with real names of the project, targets and the endpoint itself.

  • • Security is identical to POST: same Bearer, IP allowlist, timestamp, HMAC (for GET we sign method\npath\ntimestamp instead of body), tariff guard.
  • • Separate rate-limit 60 requests/min — discovery doesn't consume publishing budget. Violations are still counted toward the shared soft-block bucket so flood via /info doesn't bypass automation.
  • paused_until for project and target is always visible — clients know whether the post will be queued.
  • capabilities.limits — client-side validator can check text/media/pauses before sending and avoid 422.

⚠️ We don't return exact rate-limit values (min_interval, hourly_quota) — exposing them gives an attacker with a stolen token a protocol cheatsheet.

MCP server — for Claude Desktop, Cursor, Cline

Connect Crosslybot directly to your AI assistant — it can publish posts, find targets by name, and schedule channel pauses via simple commands.

Hosted (recommended) — nothing to install locally, just a URL in your AI client's config:

// claude_desktop_config.json
{
  "mcpServers": {
    "crosslybot": {
      "url": "https://mcp.crosslybot.com/sse/{slug}",
      "headers": {
        "Authorization": "Bearer crossly_live_..."
      }
    }
  }
}

Slug and token come from your webhook IN card in the dashboard. Toggle «Reveal names in discovery (/info)» so the AI can find targets by name.

Tools the AI receives automatically:

  • crosslybot_discover — list of your projects and publication targets (Telegram/VK/Max).
  • crosslybot_publish — publish a post. Accepts tgt_… or target name ("marketing"). Supports all payload fields including auto-pause for project / per-target.

With HMAC protection — add one more header:

"headers": {
  "Authorization": "Bearer crossly_live_...",
  "X-Crosslybot-Hmac-Secret": "..."
}

⚠️ If IP allowlist is enabled, add our MCP server IP to the whitelist: 89.223.125.61.

Self-hosted via Docker (multi-arch) — see GitHub or image ghcr.io/antiblef/crosslybot-mcp. Docs: /mcp.

Retry policy (for clients)

When to retry, and after what delay.

Situation Retry? Strategy
200 / 202NoDone
4xx (except 429)NoYour client error — retries won't help. Read errors[]
429YesHonor Retry-After header. If absent — backoff 2s/4s/8s/16s
503YesBackoff 5s/15s/45s/2 min/5 min — max 5 attempts
5xx (except 503)YesExponential backoff 2s/4s/8s/16s — max 4 attempts. Always same Idempotency-Key

⚠ IMPORTANT: when retrying, use the same Idempotency-Key — otherwise a duplicate post will be created.

Rate limits & quotas

Four-tier per-slot protection: configurable min-interval + burst + hourly quota by plan + auto-pause on systematic abuse. Excess → 429 Too Many Requests with Retry-After header.

Plan Min-interval Burst (per slot) Hourly quota Webhook IN
Free / Mini / Standardunavailable
Pro1–60 sec10 req/sec100
Maxi1–60 sec10 req/sec300
Business1–60 sec10 req/sec1,000
  • Per-slot isolation: limits are tied to the inbound webhook URL (slug), not to the account — abuse on one webhook doesn't affect others.
  • Min-interval: "no more than one request per N seconds", configured in the endpoint card (default 10s). Protects from accidental flood due to a bug in sender's bot/script.
  • Burst: sliding window 1 second. Protects from instant flood.
  • Hourly quota: resets at the UTC hour boundary.
  • Auto-pause: after 5 limit violations within an hour (any level — min-interval, burst, or hourly quota) the endpoint is auto-disabled (is_active=false). Owner gets a Telegram bot notification. Reactivated manually in UI after fixing the cause — no auto-recovery, so the cycle "pause→repeat abuse→pause" doesn't repeat.

Post quota (same as regular targets): each successful webhook post = 1 post in monthly plan quota. Don't confuse with rate limit (abuse protection) and post quota (billing).

Additional inbound webhook protections

On top of HTTPS + Bearer token, the endpoint card has optional protections for higher security.

  • HMAC signature from sender. Defense-in-depth: sender signs the body with a secret, we verify on our side. Even if the Bearer token leaks — without the secret, requests can't be forged. Toggle in the endpoint card. Algorithm matches our outbound variant (see Webhook OUT below) — one HMAC function for both directions. Code samples below.
  • Replay protection via timestamp. With require_timestamp=on we require an X-Crosslybot-Timestamp header (Unix seconds) within ±5 min window. Useful if body+token might leak into shared HTTPS logs or be intercepted: the request is valid only in a 5-minute window, otherwise — 401.
  • IP allowlist (CIDR). List of IP/CIDR from which we accept requests. Requests from other addresses get an identical 401 (timing-safe — we don't reveal that allowlist is configured). Useful if the sender has a static IP — for cloud SaaS (n8n cloud, Zapier, Make, AWS Lambda) the IP pools are dynamic, so it's better not to use it there.
  • Public URL rotation. If the webhook URL leaks into open sources — click "Rotate URL" in the endpoint card. A new /v1/webhooks/<new-slug> is generated; the old one stops working immediately. 24h cooldown protects from accidental clicks. Endpoint, token and all project bindings are preserved — just update the URL on senders.
  • Request history in UI. On the endpoint page — last 100 requests with filters by status. You see IP, decision, reason, latency, payload size. Handy for integration debugging and incident investigation.

Signing the request from sender (when require_signature is on)

Algorithm: HMAC-SHA256(secret, raw_body) → hex → header X-Crosslybot-Client-Signature: sha256=<hex>. Format without the sha256= prefix and any hex case (UPPERCASE/lowercase) are also accepted. Signature is validated before main request processing; on mismatch — identical 401 (same as for invalid token, timing-safe).

bash + openssl
SECRET="your-generated-secret"
BODY='{"text":"hello"}'
SIG=$(printf "%s" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')

curl -X POST https://wh.crosslybot.com/v1/webhooks/<slug> \
  -H "Authorization: Bearer crossly_live_..." \
  -H "Content-Type: application/json" \
  -H "X-Crosslybot-Client-Signature: sha256=$SIG" \
  --data-binary "$BODY"
Python
import hmac, hashlib, json, requests

SECRET = "your-secret"
body_bytes = json.dumps({"text": "hello"}).encode("utf-8")
signature = hmac.new(SECRET.encode(), body_bytes, hashlib.sha256).hexdigest()

resp = requests.post(
    "https://wh.crosslybot.com/v1/webhooks/<slug>",
    headers={
        "Authorization": "Bearer crossly_live_...",
        "Content-Type": "application/json",
        "X-Crosslybot-Client-Signature": f"sha256={signature}",
    },
    data=body_bytes,  # IMPORTANT: data=, not json= — exact bytes required
)
Node.js
import { createHmac } from "crypto";

const SECRET = "your-secret";
const body = JSON.stringify({ text: "hello" });
const signature = createHmac("sha256", SECRET).update(body).digest("hex");

await fetch("https://wh.crosslybot.com/v1/webhooks/<slug>", {
  method: "POST",
  headers: {
    "Authorization": "Bearer crossly_live_...",
    "Content-Type": "application/json",
    "X-Crosslybot-Client-Signature": `sha256=${signature}`,
  },
  body, // IMPORTANT: exact bytes, don't re-serialize
});
Go
import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "net/http"
)

secret := []byte("your-secret")
body := []byte(`{"text":"hello"}`)

mac := hmac.New(sha256.New, secret)
mac.Write(body)
sig := hex.EncodeToString(mac.Sum(nil))

req, _ := http.NewRequest("POST", "https://wh.crosslybot.com/v1/webhooks/<slug>", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer crossly_live_...")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Crosslybot-Client-Signature", "sha256="+sig)
http.DefaultClient.Do(req)

⚠ Signature is computed over the exact body bytes. If your HTTP library re-serializes JSON between signing and sending — the signature won't match and you'll get 401. Use data=bytes / --data-binary / equivalents.

Webhook OUT: HMAC signature verification

When Crosslybot sends a post to your URL (outbound webhook), we sign the request body with HMAC-SHA256 and put the signature in X-Crosslybot-Signature. This lets the receiver confirm the request really came from Crosslybot.

Outbound webhook headers
X-Crosslybot-Signature: sha256=<hex>
X-Crosslybot-Delivery-Id: <uuid>
X-Crosslybot-Timestamp: <unix-seconds>
X-Crosslybot-Event: post.published
Idempotency-Key: <uuid>
Content-Type: application/json
Receiver-side verification (Python)
import hmac, hashlib

def verify(secret: str, raw_body: bytes, header_signature: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), raw_body, hashlib.sha256
    ).hexdigest()
    # constant-time compare to avoid timing attacks
    return hmac.compare_digest(expected, header_signature)

# Example (Flask):
# raw = request.get_data()
# sig = request.headers["X-Crosslybot-Signature"]
# if not verify(SECRET, raw, sig): abort(401)

Token management

  • Plaintext is shown ONCE — on creation or rotate. Save it to your secret manager immediately
  • Prefixes: crossly_live_ for prod, crossly_test_ for testing (easy to grep in logs)
  • Rotate: instant replacement. Old token stops working immediately
  • Revoke: in UI you can revoke a token or temporarily deactivate the endpoint
  • Multiple tokens: in MVP — one active token per endpoint. To give a token to a second integration — create a separate webhook

Best practices

  • Token storage: only in env-vars or secret managers. Never in code, never in Git
  • Always Idempotency-Key: even if not retrying right now. Protects from accidental duplicates on failures
  • User-Agent: identify your client — easier debugging via request history
  • trace_id: end-to-end identifier from your service to publish — useful for distributed tracing
  • Sandbox before prod: POST /v1/sandbox/test as CI/CD smoke test on every deploy
  • Media size limits: compress before sending if you know targets have smaller limits — avoid extra AI processing
  • Monitor responses: log status_code and trace_id from 200/202 responses
  • Don't block your code: webhook send should be a background task with retry, not in user request hot-path

Plans and limits

Sandbox tester is available on any plan. Real webhook ingestion and outbound delivery — paid plans only.

Capability Free Mini Standard Pro Maxi Business
Sandbox /v1/sandbox/test 100/day 1k/day
Webhook output
Webhook input
Burst rate (input) 1/5s 1/2s 1/1s
Quota / hour (input) 300 1,000 5,000

Ready to try?

Create a webhook in a minute, validate format via sandbox, plug in your AI or backend.