Retry a request without duplicates
When a response gets lost (the network dropped, the request timed out), you cannot tell whether Praeko received it. Send the Idempotency-Key header and the server runs the request only once: a retry gets the same response as the first attempt.
Rules
| Rule | Value |
|---|---|
| Where it applies | Authenticated POST, PATCH and DELETE routes under /v1 (API key or panel session). GET is never stored; PUT is already safe to repeat. |
| The key | 1 to 255 visible ASCII characters, no spaces. Use a UUID. Anything else: 422 invalid_field. |
| Scope | Workspace, route and key. The same key in another workspace, or on another route, is a different key. No response ever crosses workspaces. |
| Lifetime | 24 hours after the first request. |
| Same request | Same method, path, query and body (and the same X-Praeko-Contract): the stored response is replayed with Idempotent-Replayed: true. |
| Different request | Same key with another method, path, query or body: 422 idempotency_key_reuse. Nothing runs. |
| First request still running | 409 idempotency_in_progress, with Retry-After: 1. Retry in a moment. |
| Server error | A 5xx response is not stored: the key is released and the next attempt really runs. |
| Client error | A 4xx response (validation, 404, 409…) is stored and replayed. Fix the request and use a new key. |
| Large response | Above 256 KB only the status is stored; a retry gets 409 idempotency_response_too_large. Read the resource with GET. |
| Orphaned request | If the server dies mid-request, the reservation can be taken over after 2 minutes. |
| Not covered | PUT /v1/media/:id/parts/:part and PUT /v1/media/:id/content (raw bytes, already safe to repeat: sending the same part again is fine). Creating (POST /v1/media) and completing (POST /v1/media/:id/complete) accept the key. |
Authentication errors and the rate limit (401, 403, 429) are answered before the key is checked and are never stored.
The three errors
| Status | `code` | What to do |
|---|---|---|
| 422 | idempotency_key_reuse |
The key was already used with another request. Generate a new key for the new request. |
| 409 | idempotency_in_progress |
The first request is still running. Wait for Retry-After and retry with the same key. |
| 409 | idempotency_response_too_large |
The original response was over 256 KB and was not stored. Read the resource with GET. |
curl
KEY=$(uuidgen)
curl -X POST https://praeko.app/v1/ideas \
-H "Authorization: Bearer $PRAEKO_KEY" -H "X-Praeko-Contract: en" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"title":"The bug that only showed up on Mondays"}'
# the network dropped and you do not know if it went through: repeat it EXACTLY, with the same key
curl -i -X POST https://praeko.app/v1/ideas \
-H "Authorization: Bearer $PRAEKO_KEY" -H "X-Praeko-Contract: en" \
-H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
-d '{"title":"The bug that only showed up on Mondays"}'
# HTTP/2 200 … idempotent-replayed: true (same body, only one idea on the board)JavaScript with fetch
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function createPost(post) {
const key = crypto.randomUUID(); // one key per request, the same on every attempt
for (let i = 0; i < 3; i++) {
try {
const r = await fetch("https://praeko.app/v1/posts", {
method: "POST",
headers: { authorization: `Bearer ${process.env.PRAEKO_KEY}`, "x-praeko-contract": "en", "content-type": "application/json", "idempotency-key": key },
body: JSON.stringify(post),
});
if (r.status === 409 && (await r.clone().json()).error.code === "idempotency_in_progress") { await sleep(1000); continue; }
return r;
} catch { /* network failure: retry with the same key */ }
}
throw new Error("no response after 3 attempts");
}SDK (@praeko/sdk)
Every call that writes accepts the idempotencyKey option. With true, the SDK generates a UUID and resends the request with the same key on a network failure, a 429 or a 5xx. With a string, you pick the key (for example, one of your job ids, so it survives a process restart).
import { Praeko } from "@praeko/sdk";
const praeko = new Praeko({ url: "https://praeko.app", key: process.env.PRAEKO_KEY });
// 1) the SDK generates the UUID and retries the POST with the same key if the network fails
const post = await praeko.posts.create({ title, media: { video: video.id }, texts, items }, { idempotencyKey: true });
// 2) or pick the key yourself
await praeko.webhooks.create({ url: "https://example.com/hooks/praeko", idempotencyKey: `hook-${jobId}` });Without idempotencyKey, the SDK never retries a POST, PATCH or DELETE (only GET and upload parts). These accept the option: media.create and media.complete, posts.create, adopt, update, cancel, publishNow and scheduleYoutube, webhooks.create, rotateSecret and redeliver, ideas.create and import, apiKeys.create, youtube.reply and instagram.reply.
CLI
praeko publish-now <id> --idempotency-key K
praeko webhook create --url https://example.com/hooks/praeko --idempotency-key Kpublish-now and webhook create send the header. The CLI does not retry on its own: the option is for when you run the command again, for example from a script with retries.
What was already safe to resend
| Situation | What to do |
|---|---|
POST /v1/posts with your own id |
Send your own id in the body: a repeat returns 409 id_exists and nothing is duplicated. Works even without Idempotency-Key. |
| Repeated media part | Safe: PUT /v1/media/:id/parts/:part overwrites the same part. |
| Repeated webhook delivery | Praeko-Delivery is the same on every attempt: keep the ids you already handled and ignore repeats. |
POST /v1/insights/import |
Idempotent: it upserts by network and id. |
POST /v1/ideas/import |
Idempotent by each idea's old id (only ideas that do not exist yet are added). |