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. |
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.
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