Skip to main content

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:

  1. A Soku account with an active subscription (trialing or active status)
  2. An API key (created from the web app)
  3. 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:

ScenarioStatusCodeMessage
Missing header401unauthorizedMissing or invalid soku-api-key
Invalid key401unauthorizedInvalid API key
Revoked key401unauthorizedAPI key revoked
No subscription403forbiddenSubscription required
Expired subscription403forbiddenSubscription inactive or expired

Subscription requirement

All Public API endpoints require an active subscription. Allowed subscription states:

  • trialing — during your free trial period
  • active — with currentPeriodEnd in 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

HeaderRequiredDescription
Content-TypeYesMust be application/json
soku-api-keyYesYour API key
Idempotency-KeyNoPrevents duplicate processing (see Idempotency)
X-Request-IDNoYour own request ID for tracking/debugging

Response headers

Every response includes:

HeaderDescription
X-Request-IDUnique request identifier (yours or auto-generated)
X-RateLimit-LimitMax requests allowed in current window
X-RateLimit-RemainingRequests remaining in current window
X-RateLimit-ResetISO 8601 timestamp when the window resets
X-RateLimit-TierYour current rate limit tier
Idempotency-KeyThe 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

FieldTypeRequiredDescription
post.content.textstringNoThe text content of your post
post.content.platformstring, object, or arrayYesWhere to publish (see Platform targeting)
post.content.mediaTypestringNo"video", "image", or "text". Auto-detected if omitted
post.content.videoUrlstring (URL)NoURL to a video file
post.content.imageUrlsstring[] (URLs)NoArray of image URLs (for carousel/multi-image posts)
post.content.mediaUrlsstring[] (URLs)NoArray of media URLs (generic)
post.content.mediaIdsstring[]NoArray of media IDs from the /v1/media endpoint
post.content.originalImageUrlstring (URL)NoOriginal source image URL
post.content.originalVideoUrlstring (URL)NoOriginal source video URL
post.content.originalImageUrlsstring[] (URLs)NoOriginal source image URLs
post.content.originalMediaUrlsstring[] (URLs)NoOriginal source media URLs
scheduledTimestring (ISO 8601)NoSchedule for future publishing (see Scheduling)
enableRepurposingbooleanNoWhen 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:

PlatformValue
X (Twitter)x
Instagraminstagram
Facebookfacebook or facebook_pages
Threadsthreads
TikToktiktok
YouTubeyoutube
LinkedInlinkedin
Snapchatsnapchat

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 accountId is the integration account ID found under users/{uid}/integrations/{platform}/accounts/{accountId} in Firestore. Check the web app Integrations page for your connected accounts.
  • targetId is 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:

ScenarioStatusCodeMessage
Invalid format400validation_errorInvalid scheduledTime format
In the past400validation_errorscheduledTime 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}):

  1. queued — Post submission created, task enqueued
  2. orchestrating — Orchestrator is processing and dispatching to platforms
  3. dispatched — Platform-specific publish tasks have been sent

Each platform's result is tracked at postSubmissions/{id}/runs/{platform} with statuses: queuedprocessingsucceeded 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"
}
FieldTypeRequiredDescription
urlstring (URL)YesThe 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"
}
FieldTypeDescription
urlstringThe 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
}
FieldTypeRequiredDescription
mediaIdstringYesID of a media document (from /v1/media upload)
languagestringNoISO 639-1 language code (e.g., "en", "es", "fr"). Auto-detected if omitted
modelstringNoOpenAI model to use. Defaults to "whisper-1"
durationSecondsintegerNoMedia 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-Key header 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
}
FieldTypeDescription
jobIdstringUnique identifier for this transcription job
transcriptstringThe full transcribed text
creditsDebitednumberNumber of AI credits charged
likelyLyricsbooleanWhether the content appears to be song lyrics

Errors:

CodeStatusDescription
MEDIA_NOT_FOUND404The mediaId does not exist
MEDIA_INVALID400Media document is missing required fields
UNSUPPORTED_MEDIA_TYPE400Media is not audio or video
insufficient_credits402Not 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"
}
FieldTypeRequiredDescription
templateSlugstringYesTemplate identifier (e.g., "tweetImage")
configobjectNoTemplate-specific configuration (key-value pairs)
aspectRatiostringNoOutput aspect ratio: "1:1", "4:5", or "9:16"

Response

Success (200 OK):

{
"url": "https://storage.mysoku.io/rendered_abc123.png",
"id": "rendered_abc123"
}
FieldTypeDescription
urlstringPublic URL for the rendered image
idstringUnique 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"
}
FieldTypeRequiredDescription
namestringNoA 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

  1. Explicit key: Include an Idempotency-Key header with a unique string (e.g., a UUID).
  2. 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

ScenarioResult
First request with a keyProcessed normally
Replay with same key + same bodyReturns the original result (no duplicate task created)
Same key + different body409 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

FieldTypeAlways presentDescription
typestringYesError category (see below)
codestringYesMachine-readable error code
messagestringYesHuman-readable description
timestampstringYesISO 8601 when the error occurred
requestIdstringYesRequest ID for support/debugging
detailsarray/objectNoAdditional context (validation errors, etc.)
debugobjectNoStack trace (development mode only)

Error types

TypeDescription
invalid_request_errorThe request was malformed or invalid (4xx)
authentication_errorAuthentication or authorization failed (401/403)
api_errorServer-side error (5xx)

Error codes reference

CodeStatusWhen it occurs
unauthorized401Missing, invalid, or revoked API key
forbidden403Subscription required or expired
not_found404Resource or route not found
validation_error400Request body failed schema validation
idempotency_conflict409Same idempotency key used with different payload
rate_limit_exceeded429Too many requests (check Retry-After header)
upload_failed400Media upload failed (bad URL, unreachable, etc.)
missing_integrations400Platform not connected or accountId not found
post_creation_failed500Internal error creating post submission
insufficient_credits402Not enough AI credits for the operation
internal_error500Unexpected 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:

TierWhoLimitBurst
DefaultUnauthenticated25 req/min+10
AuthenticatedValid API key60 req/min+30
PremiumActive subscription100 req/min+100
AdminWhitelisted users/IPs200 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:

EndpointLimit
POST /v1/posts30 req/min
POST /v1/media30 req/min
POST /v1/ai/transcribe10 req/min
POST /v1/templates20 req/min
POST /v1/media/render-og20 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

ProblemCauseSolution
401 unauthorizedBad or missing API keyCheck the soku-api-key header. Make sure the key is not revoked.
403 forbiddenSubscription issueVerify your subscription is active or trialing and not expired.
400 validation_errorMalformed request bodyCheck the details array for specific field errors. Compare against the schema.
400 missing_integrationsPlatform not connectedConnect the platform in the web app, or verify the accountId is correct.
409 idempotency_conflictSame key, different payloadUse a unique Idempotency-Key for different requests.
429 rate_limit_exceededToo many requestsWait for the Retry-After seconds, then retry. Implement exponential backoff.
Post queued but not publishedIntegration token expiredRe-authenticate the platform in the web app. Check the runs sub-collection for error details.

Debugging tips

  1. Check the request ID: Every response includes X-Request-ID. Save this for debugging.
  2. Send your own request ID: Set X-Request-ID: my-debug-123 to correlate with logs.
  3. Check Firestore: Post progress is tracked at postSubmissions/{id} and per-platform at postSubmissions/{id}/runs/{platform}.
  4. Check API request logs: Your requests are logged at users/{uid}/apiRequests in Firestore.

Security headers

All API responses include security headers:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policystrict-origin-when-cross-origin
X-DNS-Prefetch-Controloff
Strict-Transport-Securitymax-age=63072000; includeSubDomains