Publishing
Idempotency & Retries
Retry post creation safely with Idempotency-Key, and understand how publishing is retried.
Networks fail. A request can time out after the server has already created your post, and if you simply retry you might publish twice and pay twice. Relaycast handles this with idempotency keys on the client side and automatic retries on the publishing side.
Idempotency keys
Send an Idempotency-Key header with POST /v1/posts. The first request with a given key creates the post. Any later request with the same key, in the same project, returns that original post instead of creating a new one.
curl -X POST https://social-publish-api.onrender.com/v1/posts \
-H "x-access-key: $RELAYCAST_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-announcement" \
-d '{
"content": "Thanks for order #1042!",
"targets": [{ "accountId": "8f1c2a4e-5b6d-4e7f-9a0b-1c2d3e4f5a6b" }],
"publishNow": true
}'import { randomUUID } from "node:crypto";
async function createPost(body: unknown, key = randomUUID()) {
for (let attempt = 1; ; attempt++) {
try {
const res = await fetch("https://social-publish-api.onrender.com/v1/posts", {
method: "POST",
headers: {
"x-access-key": process.env.RELAYCAST_API_KEY!,
"Content-Type": "application/json",
"Idempotency-Key": key, // same key on every attempt
},
body: JSON.stringify(body),
});
if (res.status === 429 || res.status >= 500) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt >= 4) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
}
}
}import os
import time
import uuid
import requests
def create_post(body, key=None):
key = key or str(uuid.uuid4())
for attempt in range(1, 5):
try:
res = requests.post(
"https://social-publish-api.onrender.com/v1/posts",
headers={
"x-access-key": os.environ["RELAYCAST_API_KEY"],
"Idempotency-Key": key, # same key on every attempt
},
json=body,
timeout=30,
)
if res.status_code == 429 or res.status_code >= 500:
raise RuntimeError(f"HTTP {res.status_code}")
return res.json()
except (requests.RequestException, RuntimeError):
if attempt == 4:
raise
time.sleep(2 ** attempt)How replays behave
| Situation | Response |
|---|---|
| First request with a key | 201 Created with the new post |
| Same key again | 200 OK with the original post, as it is now |
| Two requests with the same key at the same time | One creates the post; the other returns it with 200 |
| No key | Every request creates a new post |
A replay doesn't charge credits, re-run validation or queue anything again. It returns the post's current state, so a replay sent a few minutes later may already show targets as published.
Choosing keys
- Use a value that identifies the intent, such as
newsletter-2026-10-05or the ID of the record in your system that triggered the post. A random UUID generated once per logical post also works. - Generate the key before the first attempt and reuse it for every retry of that post.
- Keys are compared as exact strings and scoped to your project. Different projects can use the same key independently.
- Keys don't expire. A key keeps pointing at its post until that post is deleted.
The request body is not compared on replay. If you reuse a key with a different body, you get the original post back and the new body is ignored. Use a new key for every distinct post.
What to retry
Retry with the same key on network errors, timeouts, 429 and 5xx. Don't retry 400, 401, 402 or 404 without changing something first, since the same request will fail the same way. See Errors.
Failed validation (400) doesn't store the key, so you can fix the body and retry with the same key.
Publish retries
After a post is accepted, each target is published by a background worker. If a platform returns a temporary error (a timeout, a 5xx, or a rate limit), the target goes back to queued and is retried automatically with exponential backoff, up to 5 attempts in total. The attempts field on each target shows how many have been made, and errorCode / errorMessage show the last error while it waits.
Permanent errors, such as rejected content, a media URL returning 404, or revoked access, fail the target straight away without retrying.
When a target fails for good:
- its
statusbecomesfailed, - its credit is refunded,
- and if the error was
auth_revoked, the account is markedreconnect_required.
Targets are retried independently, so one slow platform never holds back the others.
Duplicate protection
Each target has exactly one publish job, and a target that's already published or failed is never published again. If a job is lost (for example while infrastructure restarts), a sweeper re-queues targets still waiting a couple of minutes after their scheduled time, without creating duplicates.
This page is also available as Markdown.