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.
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"}
]]
}' 200 OK · {"ok": true, "id": "post_..."} POST /v1/sandbox/test Use cases
6 detailed guides covering main scenarios. From quick-start cURL to AI agents and no-code workflows.
How to publish a post to Telegram via cURL in 5 minutes
5 minutes from sign-up to first post via API. cURL command inside.
REST API for sending posts to Telegram, VK and Max from your backend
One webhook, three platforms. Bearer token, JSON, REST.
AI agent autoposting (ChatGPT, Claude) to Telegram, VK and Max
AI writes — Crosslybot publishes. One webhook, all social networks.
n8n integration with Telegram, VK and Max via webhook
n8n collects data — Crosslybot publishes. One HTTP Request node.
Auto-publishing posts from Notion to Telegram, VK and Max
Notion content calendar → automatic publishing to TG/VK/Max.
Cross-posting RSS feeds to Telegram, VK and Max
RSS arrives — Crosslybot publishes. News aggregator without copy-paste.
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
Authorizationheader - Content-Type:
application/json - Encoding: UTF-8
- Request size: up to 5 MB
HTTP request headers
| Header | Required | Description |
|---|---|---|
| Authorization | yes | Bearer crossly_live_… or crossly_test_… Plaintext value is shown once on creation — save it immediately. |
| Content-Type | yes | application/json |
| Idempotency-Key | no | UUID or unique string. Repeated POST with same key within 24h returns cached response. Up to 128 chars. |
| User-Agent | no | Recommended: 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 |
|---|---|---|
| text | string | Post text. IN: plain text without HTML tags. OUT: plain (HTML tags stripped). Limit — most generous of platforms (15895 for VK). |
| entities | array | Formatting 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[] | array | Media: 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[][] | array | URL 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_advertisement | bool | Ad post marker. Not required for pause logic — used as a label for analytics |
| ad_pause_minutes | int | 0–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_minutes | int | 0–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_id | string | Alternative to Idempotency-Key (can be used together). Up to 128 chars |
| trace_id | string | Custom ID for end-to-end debugging. Returned in response and logs |
| metadata | object | Custom 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
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 |
|---|---|---|
| 200 | Accepted, queued | Done |
| 202 | Already accepted (Idempotency) | Same result — no action |
| 400 | Invalid payload | Read errors[] and fix |
| 401 | Invalid or revoked Bearer | Rotate token |
| 402 | Plan doesn't allow | Upgrade plan / buy posts pack |
| 403 | Endpoint deactivated | Reactivate in UI |
| 404 | endpoint_id not found | Check URL |
| 422 | Invalid JSON schema | Read errors[] in body — per-field details there |
| 429 | Rate limit exceeded | Honor Retry-After header |
| 503 | Webhook API temporarily unavailable | Retry with backoff |
| 5xx | Internal error | Retry 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 |
|---|---|---|
| accepted | 200 | Accepted, post created and queued for publishing |
| accepted_no_project | 200 | Accepted, but webhook is not bound to any project — post is not created (silent) |
| accepted_idempotent_replay | 200 | Duplicate by Idempotency-Key, cached response returned |
| rejected_auth | 401 | Slug not found, token mismatch, IP outside allowlist, signature failed or timestamp out of window — client gets identical response, exact reason is in journal |
| rejected_validation | 400/415/422 | Invalid JSON, wrong Content-Type, payload >5MB or schema validation failed (details in errors[]) |
| rejected_rate_limit | 429 | One of the limits exceeded: min-interval, burst, or hourly quota. Honor Retry-After |
| rejected_paused | 503 | Endpoint is disabled (manually or auto-paused after rate-limit violations) |
| rejected_tier | 503 | Current plan no longer allows webhook IN — endpoint auto-disabled |
| rejected_disabled | 503 | Webhook API temporarily unavailable (maintenance) |
Code examples
Replace 12345 with your endpoint_id and crossly_live_... with your token.
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"}
}' 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()) 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+: ∞
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"}
]
}' {
"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_minutesandad_target_pause_minutesare 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_advertisementis 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_minutesmust be > 0. - •
target_minutes— global resource pause: applies across all your projects with that channel. - • If
targetsis given but noidbelongs 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\ntimestampinstead 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_untilfor 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. Acceptstgt_…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 / 202 | No | Done |
| 4xx (except 429) | No | Your client error — retries won't help. Read errors[] |
| 429 | Yes | Honor Retry-After header. If absent — backoff 2s/4s/8s/16s |
| 503 | Yes | Backoff 5s/15s/45s/2 min/5 min — max 5 attempts |
| 5xx (except 503) | Yes | Exponential 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 / Standard | — | — | — | unavailable |
| Pro | 1–60 sec | 10 req/sec | 100 | ✓ |
| Maxi | 1–60 sec | 10 req/sec | 300 | ✓ |
| Business | 1–60 sec | 10 req/sec | 1,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=onwe require anX-Crosslybot-Timestampheader (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).
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" 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
) 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
}); 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.
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 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.