cross-post API
Programmatically manage your social media accounts, create and schedule posts, upload media, and track analytics across all your connected platforms. New to cross-post? Visit the homepage to create an account and get your API key.
Base URL
https://cross-post.app/api/v1
All requests and responses use JSON. Set the Content-Type: application/json header on all requests with a body.
Supported Platforms
cross-post supports the following platforms: Instagram, YouTube, TikTok, X (Twitter), Threads, Bluesky, and Pinterest.
Authentication #
Authenticate API requests using a Bearer token. Generate API keys from your cross-post dashboard under Settings > Developer API.
Include your API key in the Authorization header of every request:
curl https://cross-post.app/api/v1/accounts \
-H "Authorization: Bearer cp_live_xxxxx..."
API Key Format
API keys use the prefix cp_live_ followed by 40 hex characters. Keep your keys secret and never expose them in client-side code.
Scopes
Each API key can be assigned granular scopes that limit what it can access:
| Scope | Description |
|---|---|
accounts:read | List connected social accounts |
accounts:write | Connect and disconnect social accounts |
posts:read | List and retrieve posts |
posts:write | Create and delete posts |
media:write | Upload media files |
analytics:read | View analytics data |
usage:read | View subscription usage and limits |
webhooks:read | List registered webhooks |
webhooks:write | Create and delete webhooks |
* | Wildcard — grants all scopes |
403 Forbidden error with code insufficient_scope.Rate Limiting #
API requests are rate-limited to 60 requests per minute per API key by default. This limit is configurable per key. Rate limit information is included in every response via headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait before retrying (only present on 429 responses) |
When you exceed the limit, the API returns 429 Too Many Requests. Use the Retry-After header to determine how long to wait before retrying.
{
"error": {
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 42 seconds",
"status": 429
}
}
Error Handling #
The API uses standard HTTP status codes and returns errors in a consistent format:
{
"error": {
"code": "validation_error",
"message": "The caption field is required.",
"status": 400
}
}
Error Codes
| Code | Status | Description |
|---|---|---|
auth_required | 401 | Missing or invalid Authorization header |
invalid_api_key | 401 | API key is invalid or has been revoked |
insufficient_scope | 403 | API key lacks the required scope for this endpoint |
validation_error | 400 | Request body or parameters failed validation |
not_found | 404 | The requested resource does not exist |
method_not_allowed | 405 | HTTP method not supported for this endpoint |
rate_limit_exceeded | 429 | Too many requests — slow down |
limit_exceeded | 403 | Account usage limit reached for your plan |
late_api_error | 500 | An error occurred communicating with the upstream publishing service |
internal_error | 500 | An unexpected error occurred on our end |
Pagination #
List endpoints return paginated results. Control pagination with query parameters:
| Parameter | Default | Max | Description |
|---|---|---|---|
page | 1 | — | Page number |
per_page | 20 | 100 | Items per page |
Paginated responses include a pagination object:
{
"data": [...],
"pagination": {
"page": 1,
"per_page": 20,
"total": 47,
"has_more": true
}
}
List Accounts #
Returns all social accounts connected to your cross-post account.
Response
{
"data": [
{
"id": 12,
"platform": "instagram",
"username": "mycreatoraccount",
"display_name": "My Creator Account",
"avatar_url": "https://...",
"connected_at": "2026-01-15 10:30:00",
"is_active": true
}
]
}
Example
curl https://cross-post.app/api/v1/accounts \
-H "Authorization: Bearer cp_live_xxxxx..."
Connect Account #
Generates an OAuth connect URL for the specified platform. Redirect the user to this URL to authorize the connection.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
platform | string | required | One of: instagram, youtube, tiktok, twitter, threads, bluesky, pinterest |
Response
{
"data": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"platform": "instagram"
}
}
Example
curl -X POST https://cross-post.app/api/v1/accounts/connect \
-H "Authorization: Bearer cp_live_xxxxx..." \
-H "Content-Type: application/json" \
-d '{"platform": "instagram"}'
Disconnect Account #
Disconnects a social account. Any scheduled posts targeting this account will fail.
Path Parameters
| Parameter | Description |
|---|---|
id | The account ID (integer, e.g., 12) |
Response
{
"data": {
"success": true
}
}
Example
curl -X DELETE https://cross-post.app/api/v1/accounts/12 \
-H "Authorization: Bearer cp_live_xxxxx..."
List Posts #
Returns a paginated list of your posts. Supports filtering by status and platform.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter by status: draft, scheduled, publishing, published, partial, failed |
platform | string | — | Filter by target platform (e.g., instagram) |
page | integer | 1 | Page number |
per_page | integer | 20 | Results per page (max 100) |
Response
{
"data": [
{
"id": 42,
"caption": "Check out our new feature!",
"status": "published",
"publish_mode": "now",
"scheduled_at": null,
"published_at": "2026-03-15 14:22:00",
"created_at": "2026-03-15 14:20:00",
"media": [
{
"url": "https://cdn.cross-post.app/media/abc123.jpg",
"filename": "abc123.jpg",
"mime_type": "image/jpeg",
"size": 284720,
"sort_order": 0
}
],
"destinations": [
{
"account_id": 12,
"platform": "instagram",
"status": "published",
"published_at": "2026-03-15 14:22:00",
"error_message": null
}
]
}
],
"pagination": {
"page": 1,
"per_page": 20,
"total": 47,
"has_more": true
}
}
Get Post #
Retrieves a single post by ID, including its media and per-destination status.
Response
Returns the same post object shape as the list endpoint, wrapped in a data key (not an array).
Example
curl https://cross-post.app/api/v1/posts/42 \
-H "Authorization: Bearer cp_live_xxxxx..."
Create Post #
Creates a new post and publishes it to the specified social accounts. Upload media first using the media upload endpoint, then reference the returned URLs here.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
caption | string | optional | Post caption / text content |
media_urls | array | optional | Array of media URLs (strings) or objects with url and type fields |
destination_account_ids | integer[] | required | Account IDs to publish to (integers) |
publish_mode | string | optional | now, schedule, or draft. Defaults to schedule. |
scheduled_at | string | if schedule | Datetime string (e.g., 2026-03-20T15:00:00). Must be in the future. |
timezone | string | optional | IANA timezone (e.g., America/New_York). Defaults to UTC. |
Idempotency
X-Idempotency-Key header with a unique string to safely retry requests without creating duplicate posts. Cached responses expire after 24 hours.Response
{
"data": {
"id": 57,
"caption": "Check out our new feature!",
"status": "scheduled",
"publish_mode": "schedule",
"scheduled_at": "2026-03-20T15:00:00",
"published_at": null,
"created_at": "2026-03-17 10:00:00",
"media": [
{
"url": "https://cdn.cross-post.app/media/abc123.jpg",
"filename": "abc123.jpg",
"mime_type": null,
"size": null,
"sort_order": 0
}
],
"destinations": [
{
"account_id": 12,
"platform": "instagram",
"status": "pending",
"published_at": null,
"error_message": null
},
{
"account_id": 15,
"platform": "twitter",
"status": "pending",
"published_at": null,
"error_message": null
}
]
}
}
Example
curl -X POST https://cross-post.app/api/v1/posts \
-H "Authorization: Bearer cp_live_xxxxx..." \
-H "Content-Type: application/json" \
-d '{
"caption": "Check out our new feature!",
"media_urls": ["https://cdn.cross-post.app/media/abc123.jpg"],
"destination_account_ids": [12, 15],
"publish_mode": "schedule",
"scheduled_at": "2026-03-20T15:00:00",
"timezone": "America/New_York"
}'
Delete Post #
Deletes a post. Only posts with status draft, scheduled, or failed can be deleted. Published or publishing posts cannot be deleted.
Response
{
"data": {
"success": true
}
}
Example
curl -X DELETE https://cross-post.app/api/v1/posts/42 \
-H "Authorization: Bearer cp_live_xxxxx..."
Upload Media #
Returns a presigned upload URL. Upload your file with a PUT request to the upload_url, then use the public_url when creating posts.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | required | Original filename with extension |
content_type | string | required | MIME type (e.g., image/jpeg, video/mp4) |
Response
{
"data": {
"upload_url": "https://storage.cross-post.app/presigned/...",
"public_url": "https://cdn.cross-post.app/media/abc123.jpg"
}
}
Two-Step Upload Flow
Step 2:
PUT the raw file to upload_url with the matching Content-Type header. Then reference public_url in your create post request.Example
# Step 1: Get presigned URL
curl -X POST https://cross-post.app/api/v1/media/upload \
-H "Authorization: Bearer cp_live_xxxxx..." \
-H "Content-Type: application/json" \
-d '{"filename": "photo.jpg", "content_type": "image/jpeg"}'
# Step 2: Upload the file
curl -X PUT "https://storage.cross-post.app/presigned/..." \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
Get Analytics #
Returns analytics for your account, including post counts, platform breakdown, and daily posting trends.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
period | string | 30d | Time period: 7d, 30d, 90d, or all |
Response
{
"data": {
"period": "30d",
"total_posts": 142,
"published_count": 128,
"failed_count": 3,
"scheduled_count": 11,
"by_status": {
"published": 128,
"failed": 3,
"scheduled": 11
},
"platforms": {
"instagram": 45,
"twitter": 38,
"youtube": 22,
"tiktok": 18,
"threads": 12,
"bluesky": 7
},
"success_rate": 97.7,
"posts_over_time": [
{ "date": "2026-03-01", "count": 5 },
{ "date": "2026-03-02", "count": 3 }
]
}
}
Get Usage #
Returns your current usage counts and subscription tier limits. A limit value of -1 means unlimited.
Response
{
"data": {
"subscription_tier": "pro",
"subscription_expires_at": "2026-04-15 00:00:00",
"posts": {
"used": 42,
"limit": 500,
"remaining": 458
},
"scheduled_posts": {
"used": 8,
"limit": 100,
"remaining": 92
},
"social_accounts": {
"used": 4,
"limit": 15,
"remaining": 11
},
"max_media_size_mb": 100
}
}
List Webhooks #
Returns all active webhook endpoints for your account. The secret is not included in list responses.
Response
{
"data": [
{
"id": 3,
"url": "https://example.com/webhooks/crosspost",
"events": ["post_published", "post_failed"],
"created_at": "2026-02-10 08:00:00"
}
]
}
Create Webhook #
Registers a new webhook endpoint. You will receive a secret in the response — store it securely for signature verification. Maximum 5 webhooks per user.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
url | string | required | HTTPS URL to receive webhook payloads |
events | string[] | required | Events to subscribe to (see events list) |
Response
{
"data": {
"id": 3,
"url": "https://example.com/webhooks/crosspost",
"events": ["post_published", "post_failed"],
"secret": "a1b2c3d4e5f6...",
"created_at": "2026-03-17 10:00:00"
}
}
secret is only returned once at creation time. Store it securely — you will need it to verify webhook signatures.Example
curl -X POST https://cross-post.app/api/v1/webhooks \
-H "Authorization: Bearer cp_live_xxxxx..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/crosspost",
"events": ["post_published", "post_failed"]
}'
Delete Webhook #
Deletes a registered webhook. No further events will be delivered to this endpoint.
Response
{
"data": {
"success": true
}
}
Example
curl -X DELETE https://cross-post.app/api/v1/webhooks/3 \
-H "Authorization: Bearer cp_live_xxxxx..."
Webhook Events & Signatures #
Available Events
| Event | Description |
|---|---|
post_created | A new post was created (any publish mode) |
post_published | A post was successfully published to all destinations |
post_failed | A post failed to publish on one or more destinations |
account_connected | A new social account was connected |
account_disconnected | A social account was disconnected |
Delivery
Webhooks are delivered as POST requests to your registered URL with a JSON payload. Respond with a 2xx status code within 10 seconds to acknowledge receipt.
Retry Policy
Failed deliveries are retried up to 5 times with exponential backoff: 30 seconds, 2 minutes, 8 minutes, 32 minutes, 2 hours.
Payload Format
The webhook body is a JSON object with event, data, and timestamp fields. The data contents vary by event type.
{
"event": "post_created",
"data": {
"post_id": 57,
"status": "scheduled",
"publish_mode": "schedule",
"platform_count": 2
},
"timestamp": "2026-03-17T14:30:00+00:00"
}
{
"event": "account_disconnected",
"data": {
"account_id": 12,
"platform": "instagram"
},
"timestamp": "2026-03-17T14:30:00+00:00"
}
Signature Verification
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
X-Webhook-Signature | HMAC-SHA256 signature of the raw request body: sha256={hex} |
X-Webhook-Event | The event type (e.g., post_created) |
User-Agent | cross-post-webhooks/1.0 |
Content-Type | application/json |
The signature is computed over the raw JSON body using your webhook secret as the HMAC key. Always verify signatures to ensure payloads are authentic.
The header format is: sha256={hex-encoded HMAC}
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express middleware example
app.post('/webhooks/crosspost', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const isValid = verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
console.log('Received event:', event.event);
res.sendStatus(200);
});
Python
import hmac
import hashlib
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
@app.route('/webhooks/crosspost', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
abort(401)
event = request.get_json()
print(f"Received event: {event['event']}")
return '', 200
Code Examples #
Creating a Post
A complete example showing how to upload media and create a scheduled post across multiple platforms.
API_KEY="cp_live_xxxxx..."
# Step 1: Get presigned upload URL
UPLOAD_RESPONSE=$(curl -s -X POST https://cross-post.app/api/v1/media/upload \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename": "promo.jpg", "content_type": "image/jpeg"}')
UPLOAD_URL=$(echo $UPLOAD_RESPONSE | jq -r ".data.upload_url")
PUBLIC_URL=$(echo $UPLOAD_RESPONSE | jq -r ".data.public_url")
# Step 2: Upload file to presigned URL
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/jpeg" \
--data-binary @promo.jpg
# Step 3: Create the post
curl -X POST https://cross-post.app/api/v1/posts \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"caption\": \"Exciting news! Check out what we have been working on.\",
\"media_urls\": [\"$PUBLIC_URL\"],
\"destination_account_ids\": [12, 15],
\"publish_mode\": \"schedule\",
\"scheduled_at\": \"2026-03-20T15:00:00\",
\"timezone\": \"America/New_York\"
}"
const API_KEY = 'cp_live_xxxxx...';
const BASE_URL = 'https://cross-post.app/api/v1';
async function createPost() {
// Step 1: Get presigned upload URL
const uploadRes = await fetch(`${BASE_URL}/media/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
filename: 'promo.jpg',
content_type: 'image/jpeg',
}),
});
const { data: { upload_url, public_url } } = await uploadRes.json();
// Step 2: Upload file to presigned URL
const fileBuffer = await fs.promises.readFile('./promo.jpg');
await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
body: fileBuffer,
});
// Step 3: Create the post
const postRes = await fetch(`${BASE_URL}/posts`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
caption: 'Exciting news! Check out what we have been working on.',
media_urls: [public_url],
destination_account_ids: [12, 15],
publish_mode: 'schedule',
scheduled_at: '2026-03-20T15:00:00',
timezone: 'America/New_York',
}),
});
const post = await postRes.json();
console.log('Post created:', post.data.id);
}
createPost();
import requests
API_KEY = 'cp_live_xxxxx...'
BASE_URL = 'https://cross-post.app/api/v1'
headers = {'Authorization': f'Bearer {API_KEY}'}
# Step 1: Get presigned upload URL
upload_res = requests.post(f'{BASE_URL}/media/upload',
headers=headers,
json={'filename': 'promo.jpg', 'content_type': 'image/jpeg'}
)
upload_data = upload_res.json()['data']
# Step 2: Upload file to presigned URL
with open('promo.jpg', 'rb') as f:
requests.put(upload_data['upload_url'],
headers={'Content-Type': 'image/jpeg'},
data=f.read()
)
# Step 3: Create the post
post_res = requests.post(f'{BASE_URL}/posts',
headers=headers,
json={
'caption': 'Exciting news! Check out what we have been working on.',
'media_urls': [upload_data['public_url']],
'destination_account_ids': [12, 15],
'publish_mode': 'schedule',
'scheduled_at': '2026-03-20T15:00:00',
'timezone': 'America/New_York',
}
)
post = post_res.json()
print(f"Post created: {post['data']['id']}")
Listing Accounts
curl https://cross-post.app/api/v1/accounts \
-H "Authorization: Bearer cp_live_xxxxx..."
const res = await fetch('https://cross-post.app/api/v1/accounts', {
headers: { 'Authorization': `Bearer ${API_KEY}` },
});
const { data: accounts } = await res.json();
accounts.forEach(account => {
console.log(`${account.platform}: ${account.username} (${account.is_active ? 'active' : 'inactive'})`);
});
import requests
res = requests.get('https://cross-post.app/api/v1/accounts',
headers={'Authorization': f'Bearer {API_KEY}'}
)
accounts = res.json()['data']
for account in accounts:
status = 'active' if account['is_active'] else 'inactive'
print(f"{account['platform']}: {account['username']} ({status})")
Setting Up a Webhook
curl -X POST https://cross-post.app/api/v1/webhooks \
-H "Authorization: Bearer cp_live_xxxxx..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/crosspost",
"events": [
"post_published",
"post_failed",
"account_connected"
]
}'
const res = await fetch('https://cross-post.app/api/v1/webhooks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/webhooks/crosspost',
events: ['post_published', 'post_failed', 'account_connected'],
}),
});
const { data: webhook } = await res.json();
console.log('Webhook ID:', webhook.id);
console.log('Secret (store this!):', webhook.secret);
import requests
res = requests.post('https://cross-post.app/api/v1/webhooks',
headers={'Authorization': f'Bearer {API_KEY}'},
json={
'url': 'https://example.com/webhooks/crosspost',
'events': ['post_published', 'post_failed', 'account_connected'],
}
)
webhook = res.json()['data']
print(f"Webhook ID: {webhook['id']}")
print(f"Secret (store this!): {webhook['secret']}")
Need help? Contact us at support@cross-post.app