Postato Docs
GuidesCookbook

Staggered publishing

Queue many posts across time without tripping rate limits.

Staggered publishing

You have a content calendar, a campaign spreadsheet, or a week of posts prepared offline. Postato deliberately does not expose a bulk endpoint: one call = one post. The right pattern is a client-side loop that spreads submissions across time and uses per-post idempotency keys.

Why stagger

Posts scheduled for the exact same scheduledAt all fire together at delivery time, and the platforms (and our internal rate limiter) throttle bursts aggressively. Jitter across minutes and you avoid:

  • Hitting our per-endpoint rate limit (120 creations / minute / tenant)
  • Hitting the target platform's own rate limit at fire time (Instagram 25/day, Twitter app-level bursts, etc.)
  • Looking spammy to platform anti-abuse systems

Shape

Loop your array. For each post, derive a stable idempotency key from a business identifier, jitter scheduledAt by a few seconds or minutes, and backoff on failure. Persist the returned postId so a retry knows what's already been created.

Reference implementation

import crypto from 'node:crypto';

type PlannedPost = {
  businessKey: string;         // YOUR stable id
  platform: string;
  accountId: string;
  postType: string;
  content: string;
  scheduledAt: Date;
  mediaIds?: string[];
};

function jitter(d: Date, maxSeconds = 90): Date {
  const offset = Math.floor(Math.random() * maxSeconds * 1000);
  return new Date(d.getTime() + offset);
}

function idempotencyKey(businessKey: string): string {
  return crypto.createHash('sha256').update(businessKey).digest('hex').slice(0, 32);
}

async function publishOne(
  post: PlannedPost,
  workspaceId: string,
  apiKey: string,
) {
  const res = await fetch(
    `https://api.postato.com.br/v1/workspaces/${workspaceId}/posts`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey(post.businessKey),
      },
      body: JSON.stringify({
        platform: post.platform,
        accountId: post.accountId,
        status: 'scheduled',
        postType: post.postType,
        content: post.content,
        scheduledAt: jitter(post.scheduledAt).toISOString(),
        media: post.mediaIds?.map((id) => ({ id })),
      }),
    },
  );

  if (res.status === 429) {
    const retryAfter = Number(res.headers.get('retry-after') ?? 5);
    await new Promise((r) => setTimeout(r, retryAfter * 1000));
    return publishOne(post, workspaceId, apiKey);
  }

  if (!res.ok) {
    throw new Error(`Failed to publish ${post.businessKey}: ${res.status}`);
  }

  return res.json() as Promise<{ id: string; status: string }>;
}

export async function publishPlan(
  plan: PlannedPost[],
  workspaceId: string,
  apiKey: string,
) {
  const results: Array<{ businessKey: string; postId?: string; error?: string }> = [];

  for (const post of plan) {
    try {
      const { id } = await publishOne(post, workspaceId, apiKey);
      results.push({ businessKey: post.businessKey, postId: id });
    } catch (err) {
      results.push({ businessKey: post.businessKey, error: String(err) });
    }
    // small inter-call sleep to stay well under the creation rate cap
    await new Promise((r) => setTimeout(r, 250));
  }

  return results;
}
import hashlib
import random
import time
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional

import httpx


def jitter(d: datetime, max_seconds: int = 90) -> datetime:
    return d + timedelta(seconds=random.randint(0, max_seconds))


def idempotency_key(business_key: str) -> str:
    return hashlib.sha256(business_key.encode()).hexdigest()[:32]


def publish_one(
    post: Dict[str, Any],
    workspace_id: str,
    api_key: str,
) -> Dict[str, Any]:
    url = f"https://api.postato.com.br/v1/workspaces/{workspace_id}/posts"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key(post["businessKey"]),
    }
    body = {
        "platform": post["platform"],
        "accountId": post["accountId"],
        "status": "scheduled",
        "postType": post["postType"],
        "content": post["content"],
        "scheduledAt": jitter(post["scheduledAt"]).isoformat(),
    }
    if post.get("mediaIds"):
        body["media"] = [{"id": mid} for mid in post["mediaIds"]]

    res = httpx.post(url, headers=headers, json=body, timeout=30)
    if res.status_code == 429:
        retry_after = int(res.headers.get("retry-after", "5"))
        time.sleep(retry_after)
        return publish_one(post, workspace_id, api_key)
    res.raise_for_status()
    return res.json()


def publish_plan(
    plan: List[Dict[str, Any]],
    workspace_id: str,
    api_key: str,
) -> List[Dict[str, Optional[str]]]:
    results = []
    for post in plan:
        try:
            data = publish_one(post, workspace_id, api_key)
            results.append({"businessKey": post["businessKey"], "postId": data["id"]})
        except Exception as err:
            results.append({"businessKey": post["businessKey"], "error": str(err)})
        time.sleep(0.25)
    return results

Choosing the business key

Good choices: your internal draft ID, a hash of (user, scheduledAt, contentHash), a UUID stored alongside the plan entry.

The point: same input, same key. If the script restarts mid-run, re-submitting is a no-op for already-published entries — Postato dedupes via Idempotency-Key.

Picking the stagger window

  • Posts to the same account, same day: stagger over 15-60 minutes at minimum.
  • Posts across different accounts / different platforms: stagger over a few seconds (just avoid same-second collisions).
  • Very high volume (hundreds): break into hourly chunks and sleep between chunks.

A stagger of 90 seconds between items is a safe default. Tune down for cross-platform plans, up for same-account-same-platform blasts.

Dry run before submitting

Before committing a 50-post plan:

  1. Build the plan locally.
  2. Log: earliest/latest scheduledAt, counts per platform per account per day.
  3. Check for collisions with posts already scheduled in Postato (GET /v1/workspaces/{id}/posts?status=scheduled).
  4. Have a human review for 30 seconds.
  5. Run.

Bulk mistakes are expensive to unwind. The minute of review is worth it.

Rescheduling a plan

No "edit many" endpoint. Loop individual PATCH /v1/workspaces/{id}/posts/{postId} with the new scheduledAt. Throttle with a small sleep between calls to stay under the PATCH rate limit.

What about failures mid-plan?

The recommended pattern above records per-post results. When processing the return value:

  • Successful entries: record postId alongside businessKey in your DB.
  • Failures: retry only those. Same businessKey → same Idempotency-Key → safe replay.

See Retry-safe posting for the full retry machinery.

On this page