Public API v1 Reference
The Soku Public API lets you publish content to multiple social media platforms from your own code. You can create posts, upload media, transcribe audio/video, and render image templates — all through simple HTTP requests.
Quick links: Getting Started | Authentication | Endpoints | Idempotency | Errors | Rate Limits | Examples
Getting Started
1. Create an API key
Go to the Soku web app Settings > API Keys and click Create Key. You will see the key once — copy and save it securely. The key looks like sk_live_....
2. Set up your environment
export SOKU_API_KEY="sk_live_your_key_here"
export SOKU_BASE_URL="https://<region>-<project>.cloudfunctions.net/api"
3. Make your first request
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Hello from the Soku API!",
"platform": ["x"]
}
}
}'
If successful, you'll receive:
{ "postSubmissionId": "550e8400-e29b-41d4-a716-446655440000" }
Prerequisites
Before using the API, make sure you have:
- A Soku account with an active subscription (
trialingoractivestatus) - An API key (created from the web app)
- At least one connected social platform (connect via the web app Integrations page)
Authentication
Every API request requires your API key in the soku-api-key header.
soku-api-key: sk_live_your_key_here
How it works:
- Your key is hashed (SHA-256) and looked up in the database
- If valid and not revoked, the request proceeds
- The API also checks that your subscription is active
Authentication errors:
| Scenario | Status | Code | Message |
|---|---|---|---|
| Missing header | 401 | unauthorized | Missing or invalid soku-api-key |
| Invalid key | 401 | unauthorized | Invalid API key |
| Revoked key | 401 | unauthorized | API key revoked |
| No subscription | 403 | forbidden | Subscription required |
| Expired subscription | 403 | forbidden | Subscription inactive or expired |
Subscription requirement
All Public API endpoints require an active subscription. Allowed subscription states:
trialing— during your free trial periodactive— withcurrentPeriodEndin the future
Base URL
https://<region>-<project>.cloudfunctions.net/api
All endpoints are prefixed with /v1/. The API accepts and returns application/json. Request bodies are limited to 1 MB.
Request headers
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | Must be application/json |
soku-api-key | Yes | Your API key |
Idempotency-Key | No | Prevents duplicate processing (see Idempotency) |
X-Request-ID | No | Your own request ID for tracking/debugging |
Response headers
Every response includes:
| Header | Description |
|---|---|
X-Request-ID | Unique request identifier (yours or auto-generated) |
X-RateLimit-Limit | Max requests allowed in current window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | ISO 8601 timestamp when the window resets |
X-RateLimit-Tier | Your current rate limit tier |
Idempotency-Key | The idempotency key used (explicit or auto-derived) |
Endpoints
POST /v1/posts
Create a post and publish it to one or more social platforms.
Authentication: API key + active subscription Rate limit: 30 requests per minute (endpoint-specific)
Request body
{
"post": {
"content": {
"text": "Your post text here",
"platform": ["x", "threads"]
}
}
}
Fields
| Field | Type | Required | Description |
|---|---|---|---|
post.content.text | string | No | The text content of your post |
post.content.platform | string, object, or array | Yes | Where to publish (see Platform targeting) |
post.content.mediaType | string | No | "video", "image", or "text". Auto-detected if omitted |
post.content.videoUrl | string (URL) | No | URL to a video file |
post.content.imageUrls | string[] (URLs) | No | Array of image URLs (for carousel/multi-image posts) |
post.content.mediaUrls | string[] (URLs) | No | Array of media URLs (generic) |
post.content.mediaIds | string[] | No | Array of media IDs from the /v1/media endpoint |
post.content.originalImageUrl | string (URL) | No | Original source image URL |
post.content.originalVideoUrl | string (URL) | No | Original source video URL |
post.content.originalImageUrls | string[] (URLs) | No | Original source image URLs |
post.content.originalMediaUrls | string[] (URLs) | No | Original source media URLs |
scheduledTime | string (ISO 8601) | No | Schedule for future publishing (see Scheduling) |
enableRepurposing | boolean | No | When true, the published post will also trigger your automation workflows (reposting and repurposing). Defaults to false — posts from the API skip automations by default. |
trial_reel | boolean | No | When true, Instagram Reels are posted as trial reels with limited visibility. Lets creators test content with a small audience before publishing to all followers. Defaults to false. Only applies to video/Reels content on Instagram; ignored for other platforms and non-video posts. |
Platform targeting
The platform field tells Soku which social platforms to publish to. It accepts three formats:
1. Simple string (single platform, single account):
"platform": "x"
2. Object (specify account):
"platform": { "platform": "instagram", "accountId": "ig_creator_main" }
3. Array (multiple platforms, mix formats):
"platform": [
{ "platform": "instagram", "accountId": "ig_creator_main" },
{ "platform": "facebook", "accountId": "fb_page_42" },
"threads",
"x"
]
Supported platforms:
| Platform | Value |
|---|---|
| X (Twitter) | x |
instagram | |
facebook or facebook_pages | |
| Threads | threads |
| TikTok | tiktok |
| YouTube | youtube |
linkedin | |
| Snapchat | snapchat |
Account selection rules:
- If you have one account on a platform, you can use the simple string format:
"x" - If you have multiple accounts on a platform, you must specify
accountId:{ "platform": "instagram", "accountId": "ig_business" } - The
accountIdis the integration account ID found underusers/{uid}/integrations/{platform}/accounts/{accountId}in Firestore. Check the web app Integrations page for your connected accounts. targetIdis optional but recommended for deduplication. If omitted, Soku generates one as{platform}:{accountId}.
Scheduling
To schedule a post for future publishing, include the scheduledTime field:
{
"post": {
"content": {
"text": "Good morning!",
"platform": ["x", "threads"]
}
},
"scheduledTime": "2025-09-01T08:00:00Z"
}
Rules:
- Must be a valid ISO 8601 datetime string (e.g.,
2025-09-01T08:00:00Z) - Must be at least 1 minute in the future
- If omitted or
null, the post publishes immediately
Errors:
| Scenario | Status | Code | Message |
|---|---|---|---|
| Invalid format | 400 | validation_error | Invalid scheduledTime format |
| In the past | 400 | validation_error | scheduledTime must be at least 1 minute in the future |
Response
Success (201 Created):
{
"postSubmissionId": "550e8400-e29b-41d4-a716-446655440000"
}
The postSubmissionId is your reference to track this post. The post is queued for processing — actual publishing happens asynchronously via the orchestration system.
Post lifecycle:
Your post moves through these statuses (visible in Firestore at postSubmissions/{postSubmissionId}):
queued— Post submission created, task enqueuedorchestrating— Orchestrator is processing and dispatching to platformsdispatched— Platform-specific publish tasks have been sent
Each platform's result is tracked at postSubmissions/{id}/runs/{platform} with statuses: queued → processing → succeeded or failed.
POST /v1/media
Upload remote media to Soku's Cloud Storage. Returns a stable URL you can use in posts across all platforms.
Authentication: API key + active subscription Rate limit: 30 requests per minute (endpoint-specific)
Request body
{
"url": "https://example.com/my-image.jpg"
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string (URL) | Yes | The remote URL of the media file to download and store |
Notes:
- Google Drive URLs are automatically converted to direct download links
- The file is downloaded, stored in Firebase Cloud Storage, and assigned a stable URL
- Supports images, videos, and other media formats
Response
Success (201 Created):
{
"url": "https://storage.mysoku.io/abc123def.jpg"
}
| Field | Type | Description |
|---|---|---|
url | string | The public-facing URL for the uploaded media |
Use this URL in your /v1/posts requests for videoUrl, imageUrls, or mediaUrls fields.
POST /v1/ai/transcribe
Transcribe audio or video using AI (OpenAI Whisper). This endpoint costs AI credits.
Authentication: API key + active subscription Rate limit: 10 requests per minute (endpoint-specific)
Request body
{
"mediaId": "abc123def",
"language": "en",
"model": "whisper-1",
"durationSeconds": 120
}
| Field | Type | Required | Description |
|---|---|---|---|
mediaId | string | Yes | ID of a media document (from /v1/media upload) |
language | string | No | ISO 639-1 language code (e.g., "en", "es", "fr"). Auto-detected if omitted |
model | string | No | OpenAI model to use. Defaults to "whisper-1" |
durationSeconds | integer | No | Media duration in seconds for credit calculation |
Notes:
- Only audio and video MIME types are supported
- Credits are debited from
users/{uid}/credits/current - Credit cost is approximately 1 credit per minute of audio/video
- Use the
Idempotency-Keyheader to prevent duplicate charges on retries
Response
Success (200 OK):
{
"jobId": "job_abc123",
"transcript": "Welcome to my podcast. Today we're discussing...",
"creditsDebited": 10,
"likelyLyrics": false
}
| Field | Type | Description |
|---|---|---|
jobId | string | Unique identifier for this transcription job |
transcript | string | The full transcribed text |
creditsDebited | number | Number of AI credits charged |
likelyLyrics | boolean | Whether the content appears to be song lyrics |
Errors:
| Code | Status | Description |
|---|---|---|
MEDIA_NOT_FOUND | 404 | The mediaId does not exist |
MEDIA_INVALID | 400 | Media document is missing required fields |
UNSUPPORTED_MEDIA_TYPE | 400 | Media is not audio or video |
insufficient_credits | 402 | Not enough AI credits |
POST /v1/templates
Render a dynamic OG (Open Graph) image template. Returns a hosted URL for the rendered image.
Authentication: API key + active subscription Rate limit: 20 requests per minute (endpoint-specific)
Request body
{
"templateSlug": "tweetImage",
"config": {
"title": "My Tweet",
"author": "@username",
"content": "Tweet text here"
},
"aspectRatio": "1:1"
}
| Field | Type | Required | Description |
|---|---|---|---|
templateSlug | string | Yes | Template identifier (e.g., "tweetImage") |
config | object | No | Template-specific configuration (key-value pairs) |
aspectRatio | string | No | Output aspect ratio: "1:1", "4:5", or "9:16" |
Response
Success (200 OK):
{
"url": "https://storage.mysoku.io/rendered_abc123.png",
"id": "rendered_abc123"
}
| Field | Type | Description |
|---|---|---|
url | string | Public URL for the rendered image |
id | string | Unique identifier for the rendered media |
Use the rendered image in your /v1/posts requests by including the url in imageUrls.
An alias route is also available at POST /v1/media/render-og with identical functionality.
API Key Management
These endpoints manage your API keys. They require a Firebase ID token (not an API key) and are primarily used by the web app.
Authentication: Firebase ID token via Authorization: Bearer <token>
POST /v1/api-keys
Create a new API key.
Request body:
{
"name": "My Production Key"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | A human-readable name for the key (1-100 chars) |
Response (201 Created):
{
"apiKey": "sk_live_..."
}
Save this key immediately — it is shown only once and cannot be retrieved later.
GET /v1/api-keys
List all API keys for your account.
Response:
{
"keys": [
{
"id": "a1b2c3...",
"name": "My Production Key",
"createdAt": "2025-01-15T10:00:00Z",
"revokedAt": null,
"tokenPreview": "sk_live_********c3d4"
}
]
}
DELETE /v1/api-keys/:id
Revoke an API key by its hash ID. Revoked keys immediately stop working.
Response:
{
"revoked": true
}
GET /health
Health check endpoint. Does not require authentication.
Response:
{
"status": "ok",
"version": "unknown",
"timestamp": "2025-09-01T12:00:00Z"
}
Idempotency
The API supports idempotent requests to safely handle retries without duplicate side effects.
How it works
- Explicit key: Include an
Idempotency-Keyheader with a unique string (e.g., a UUID). - Auto-derived key: If you omit the header, Soku generates a deterministic key from your user ID + request body hash. This means identical requests from the same user automatically deduplicate.
Behavior
| Scenario | Result |
|---|---|
| First request with a key | Processed normally |
| Replay with same key + same body | Returns the original result (no duplicate task created) |
| Same key + different body | 409 Conflict — idempotency key conflict |
Example
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-H "Idempotency-Key: unique-request-id-123" \
-d '{
"post": {
"content": {
"text": "This will only post once",
"platform": ["x"]
}
}
}'
If you retry this exact request with the same Idempotency-Key, you'll get the same postSubmissionId back without creating a duplicate post.
Important: The Idempotency-Key is scoped per user and per endpoint. Keys are stored in Firestore under users/{uid}/idempotency/{keyHash}.
Errors
All errors follow a consistent JSON format.
Error response structure
{
"error": {
"type": "invalid_request_error",
"code": "validation_error",
"message": "Request validation failed",
"timestamp": "2025-08-29T00:02:14.654Z",
"requestId": "abb6930d-2b57-4d44-9d63-19d5a9c8d077",
"details": [
{
"field": "body.post.content.platform",
"message": "Required",
"code": "invalid_type"
}
]
}
}
Error fields
| Field | Type | Always present | Description |
|---|---|---|---|
type | string | Yes | Error category (see below) |
code | string | Yes | Machine-readable error code |
message | string | Yes | Human-readable description |
timestamp | string | Yes | ISO 8601 when the error occurred |
requestId | string | Yes | Request ID for support/debugging |
details | array/object | No | Additional context (validation errors, etc.) |
debug | object | No | Stack trace (development mode only) |
Error types
| Type | Description |
|---|---|
invalid_request_error | The request was malformed or invalid (4xx) |
authentication_error | Authentication or authorization failed (401/403) |
api_error | Server-side error (5xx) |
Error codes reference
| Code | Status | When it occurs |
|---|---|---|
unauthorized | 401 | Missing, invalid, or revoked API key |
forbidden | 403 | Subscription required or expired |
not_found | 404 | Resource or route not found |
validation_error | 400 | Request body failed schema validation |
idempotency_conflict | 409 | Same idempotency key used with different payload |
rate_limit_exceeded | 429 | Too many requests (check Retry-After header) |
upload_failed | 400 | Media upload failed (bad URL, unreachable, etc.) |
missing_integrations | 400 | Platform not connected or accountId not found |
post_creation_failed | 500 | Internal error creating post submission |
insufficient_credits | 402 | Not enough AI credits for the operation |
internal_error | 500 | Unexpected server error |
Validation errors
When a request fails schema validation, the details array contains one entry per invalid field:
{
"error": {
"type": "invalid_request_error",
"code": "validation_error",
"message": "Request validation failed",
"requestId": "...",
"timestamp": "...",
"details": [
{
"field": "body.url",
"message": "Invalid URL format",
"code": "invalid_string",
"received": "not-a-url"
}
]
}
}
Rate Limits
The API enforces rate limits at two levels:
1. Global rate limits (all endpoints)
Applied to every request based on your authentication and subscription status:
| Tier | Who | Limit | Burst |
|---|---|---|---|
| Default | Unauthenticated | 25 req/min | +10 |
| Authenticated | Valid API key | 60 req/min | +30 |
| Premium | Active subscription | 100 req/min | +100 |
| Admin | Whitelisted users/IPs | 200 req/min | +500 |
Since all Public API endpoints require an API key and subscription, most users fall into the Premium tier (100 req/min + 100 burst = 200 max/min).
2. Endpoint-specific rate limits
Additional limits on specific endpoints to prevent abuse:
| Endpoint | Limit |
|---|---|
POST /v1/posts | 30 req/min |
POST /v1/media | 30 req/min |
POST /v1/ai/transcribe | 10 req/min |
POST /v1/templates | 20 req/min |
POST /v1/media/render-og | 20 req/min |
Rate limit headers
Every response includes rate limit information:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 2025-08-29T00:03:00.000Z
X-RateLimit-Tier: premium
When you're rate limited (429)
{
"error": {
"type": "invalid_request_error",
"code": "rate_limit_exceeded",
"message": "Rate limit exceeded. Try again in 30 seconds."
}
}
The response includes a Retry-After header (in seconds). Wait that long before retrying.
Handling rate limits in code
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get("Retry-After") || "60");
console.warn(`Rate limited. Retrying in ${retryAfter}s...`);
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
continue;
}
return response;
}
throw new Error("Max retries exceeded");
}
Examples
Post with automation workflows
Publish a post and trigger your configured repost/repurpose workflows:
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "New content dropping!",
"videoUrl": "https://example.com/video.mp4",
"platform": ["x"]
}
},
"enableRepurposing": true
}'
When enableRepurposing is true, Soku publishes the post to the specified platforms and feeds it into your automation workflows — just as if the content had been detected organically. This is useful when you want to publish through Soku while also leveraging your configured repost and repurpose automations.
Post a trial reel to Instagram
Publish a video as a trial reel on Instagram. Trial reels have limited visibility, letting you test content with a small audience before publishing to all followers:
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Testing this new video idea",
"videoUrl": "https://example.com/reel.mp4",
"platform": [{ "platform": "instagram", "accountId": "ig_creator_main" }]
}
},
"trial_reel": true
}'
The trial_reel flag is only applied to Instagram Reels (video content). It is ignored for image posts, carousels, and non-Instagram platforms.
Text post to X and Threads
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Hello from the API!",
"platform": ["x", "threads"]
}
}
}'
Image post to Instagram
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Check out this photo!",
"imageUrls": ["https://example.com/photo.jpg"],
"platform": [{ "platform": "instagram", "accountId": "ig_creator_main" }]
}
}
}'
Video post to TikTok and YouTube
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "New video is live!",
"videoUrl": "https://example.com/video.mp4",
"platform": [
{ "platform": "tiktok", "accountId": "tt_creator" },
{ "platform": "youtube", "accountId": "yt_channel" }
]
}
}
}'
Multi-account posting
Post to multiple accounts on the same platform:
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Exciting announcement!",
"imageUrls": ["https://example.com/image.jpg"],
"platform": [
{ "platform": "instagram", "accountId": "ig_personal" },
{ "platform": "instagram", "accountId": "ig_business" },
{ "platform": "facebook", "accountId": "fb_page_main" }
]
}
}
}'
Scheduled post
Schedule a post for future publishing:
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"post": {
"content": {
"text": "Good morning!",
"platform": ["x", "threads"]
}
},
"scheduledTime": "2025-09-01T08:00:00Z"
}'
Upload media, then post
# Step 1: Upload media to get a stable URL
MEDIA_RESPONSE=$(curl -s -X POST "$SOKU_BASE_URL/v1/media" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{ "url": "https://example.com/video.mp4" }')
MEDIA_URL=$(echo $MEDIA_RESPONSE | jq -r '.url')
echo "Uploaded: $MEDIA_URL"
# Step 2: Create post with the uploaded media URL
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d "{
\"post\": {
\"content\": {
\"text\": \"Check this out!\",
\"videoUrl\": \"$MEDIA_URL\",
\"platform\": [\"tiktok\", \"youtube\"]
}
}
}"
Transcribe audio/video
# Step 1: Upload media
RESPONSE=$(curl -s -X POST "$SOKU_BASE_URL/v1/media" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{ "url": "https://example.com/podcast.mp3" }')
MEDIA_ID=$(echo $RESPONSE | jq -r '.id')
# Step 2: Transcribe (with idempotency key to prevent duplicate charges)
curl -X POST "$SOKU_BASE_URL/v1/ai/transcribe" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-H "Idempotency-Key: transcribe-podcast-ep42" \
-d "{
\"mediaId\": \"$MEDIA_ID\",
\"language\": \"en\",
\"durationSeconds\": 180
}"
Render a template image
# Step 1: Render template
TEMPLATE_RESPONSE=$(curl -s -X POST "$SOKU_BASE_URL/v1/templates" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d '{
"templateSlug": "tweetImage",
"config": {
"title": "Big Announcement",
"author": "@mybrand",
"content": "We just launched our new product!"
}
}')
TEMPLATE_URL=$(echo $TEMPLATE_RESPONSE | jq -r '.url')
# Step 2: Post with rendered image
curl -X POST "$SOKU_BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $SOKU_API_KEY" \
-d "{
\"post\": {
\"content\": {
\"text\": \"Big news!\",
\"imageUrls\": [\"$TEMPLATE_URL\"],
\"platform\": [\"x\", \"threads\", \"linkedin\"]
}
}
}"
Complete workflow: upload, transcribe, render, publish
#!/bin/bash
set -e
API_KEY="your_api_key_here"
BASE_URL="https://us-central1-your-project.cloudfunctions.net/api"
# 1. Upload video
echo "Uploading video..."
VIDEO_RESPONSE=$(curl -s -X POST "$BASE_URL/v1/media" \
-H "Content-Type: application/json" \
-H "soku-api-key: $API_KEY" \
-d '{ "url": "https://example.com/my-video.mp4" }')
VIDEO_URL=$(echo $VIDEO_RESPONSE | jq -r '.url')
MEDIA_ID=$(echo $VIDEO_RESPONSE | jq -r '.id')
echo "Video uploaded: $VIDEO_URL"
# 2. Transcribe video
echo "Transcribing..."
TRANSCRIPT_RESPONSE=$(curl -s -X POST "$BASE_URL/v1/ai/transcribe" \
-H "Content-Type: application/json" \
-H "soku-api-key: $API_KEY" \
-H "Idempotency-Key: workflow-$(date +%s)" \
-d "{\"mediaId\": \"$MEDIA_ID\", \"language\": \"en\"}")
TRANSCRIPT=$(echo $TRANSCRIPT_RESPONSE | jq -r '.transcript')
echo "Transcript: ${TRANSCRIPT:0:100}..."
# 3. Render custom thumbnail
echo "Rendering thumbnail..."
TEMPLATE_RESPONSE=$(curl -s -X POST "$BASE_URL/v1/templates" \
-H "Content-Type: application/json" \
-H "soku-api-key: $API_KEY" \
-d "{
\"templateSlug\": \"videoThumbnail\",
\"config\": {
\"title\": \"New Video!\",
\"subtitle\": \"${TRANSCRIPT:0:50}...\"
}
}")
THUMBNAIL_URL=$(echo $TEMPLATE_RESPONSE | jq -r '.url')
echo "Thumbnail: $THUMBNAIL_URL"
# 4. Publish to multiple platforms
echo "Publishing..."
POST_RESPONSE=$(curl -s -X POST "$BASE_URL/v1/posts" \
-H "Content-Type: application/json" \
-H "soku-api-key: $API_KEY" \
-d "{
\"post\": {
\"content\": {
\"text\": \"${TRANSCRIPT:0:100}...\",
\"videoUrl\": \"$VIDEO_URL\",
\"imageUrls\": [\"$THUMBNAIL_URL\"],
\"platform\": [
{ \"platform\": \"tiktok\", \"accountId\": \"tt_main\" },
{ \"platform\": \"youtube\", \"accountId\": \"yt_channel\" },
{ \"platform\": \"instagram\", \"accountId\": \"ig_creator\" }
]
}
}
}")
SUBMISSION_ID=$(echo $POST_RESPONSE | jq -r '.postSubmissionId')
echo "Published! Submission: $SUBMISSION_ID"
Troubleshooting
Common issues
| Problem | Cause | Solution |
|---|---|---|
401 unauthorized | Bad or missing API key | Check the soku-api-key header. Make sure the key is not revoked. |
403 forbidden | Subscription issue | Verify your subscription is active or trialing and not expired. |
400 validation_error | Malformed request body | Check the details array for specific field errors. Compare against the schema. |
400 missing_integrations | Platform not connected | Connect the platform in the web app, or verify the accountId is correct. |
409 idempotency_conflict | Same key, different payload | Use a unique Idempotency-Key for different requests. |
429 rate_limit_exceeded | Too many requests | Wait for the Retry-After seconds, then retry. Implement exponential backoff. |
| Post queued but not published | Integration token expired | Re-authenticate the platform in the web app. Check the runs sub-collection for error details. |
Debugging tips
- Check the request ID: Every response includes
X-Request-ID. Save this for debugging. - Send your own request ID: Set
X-Request-ID: my-debug-123to correlate with logs. - Check Firestore: Post progress is tracked at
postSubmissions/{id}and per-platform atpostSubmissions/{id}/runs/{platform}. - Check API request logs: Your requests are logged at
users/{uid}/apiRequestsin Firestore.
Security headers
All API responses include security headers:
| Header | Value |
|---|---|
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY |
Referrer-Policy | strict-origin-when-cross-origin |
X-DNS-Prefetch-Control | off |
Strict-Transport-Security | max-age=63072000; includeSubDomains |
Related documentation
- Publishing Guide — Step-by-step tutorial
- API Key Lifecycle — How keys are created, stored, and revoked
- Rate Limiting — Detailed rate limit architecture
- Orchestration — How publishing works under the hood
- End-to-End Flow — Complete request lifecycle