Zernio
Zernio
Overview

Guides

ProfilesConnecting accountsMedia UploadsQueue SchedulingTimezones & SchedulingIdempotency & Safe RetriesPost LifecycleError HandlingRate LimitsPixels and Tracking TagsCommerceManyChat-style automationsReport Bugs & RequestsPlatform Settings
Dashboard
llms.txtOpenAPI
OverviewPlatformsAPI ReferenceResources
Guides

Idempotency & Safe Retries

Retry POST /v1/posts safely with the Idempotency-Key header, understand x-request-id retry detection and the 24-hour content-hash dedup, and use Idempotency-Key on the other write endpoints.


Retry POST /v1/posts safely by sending an Idempotency-Key header: a retried request returns the original post instead of creating a second one. Two more layers sit behind it: x-request-id retry detection, and a content-hash dedup that rejects identical content sent to the same account within 24 hours, whatever the headers. You need an API key and a connected account. Network timeouts and the automatic retries in n8n, Zapier and custom queues all deliver the same create request twice; these layers are what make that safe.

First call

Generate a UUID per logical post and send it as Idempotency-Key. Keep it with the job that creates the post, so every retry of that job sends the same value.

import { randomUUID } from 'crypto';
import Zernio from '@zernio/node';

const zernio = new Zernio();
const idempotencyKey = randomUUID();

const { data: created } = await zernio.posts.createPost({
  headers: { 'Idempotency-Key': idempotencyKey },
  body: {
    content: 'Our January release notes are out.',
    scheduledFor: '2027-01-01T12:00:00',
    timezone: 'America/New_York',
    platforms: [{ platform: 'linkedin', accountId: '66b2e19d8c3f5a7e9d0b1c2d' }]
  }
});

const postId = created.post._id;

Response (201):

{
  "message": "Post scheduled successfully",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "scheduled",
    "scheduledFor": "2027-01-01T17:00:00Z",
    "platforms": [
      { "platform": "linkedin", "status": "pending" }
    ]
  }
}

Send the same request again with the same Idempotency-Key and no new post is created. Response (200):

{
  "message": "Post already exists (idempotent retry)",
  "post": {
    "_id": "65f1c0a9e2b5af0012ab34cd",
    "status": "scheduled",
    "scheduledFor": "2027-01-01T17:00:00Z"
  }
}

Treat that 200 as success: it is a replay of the first call. A newly created post is always 201.

How it behaves

Layer 1: Idempotency-Key (24-hour window)

A retry with the same Idempotency-Key within 24 hours returns 200 with the original post in post. The match is on the key alone, not on the body, so a retry whose body differs (for example a re-uploaded media URL) still returns the original post. It covers drafts, and a post that was saved even though the original response was a 5xx or a timeout.

  • Generate a fresh UUID (v4 is fine) per logical post, up to 255 characters; a longer key returns 400.
  • Reuse it only when retrying that exact post after a timeout, a 5xx or a connection reset. Never reuse a key for a different post: that post would not be created, and the earlier one would come back instead.
  • While the first request is still being processed, the retry gets 409 with code: "idempotency_conflict" and a Retry-After header; retry after that delay to get the original post.
  • If the first request failed without creating a post, the retry is processed as a new request.

Keys are scoped to your user. The Python SDK takes it as idempotency_key=; with the other SDKs, pass it as a request header.

Layer 2: x-request-id retry detection (24-hour window)

A request with the same x-request-id as an earlier one that also matches its content fingerprint (same account, content and media URLs) returns 200 with the original post in post, or 202 with postId when the original is still being saved, instead of a duplicate-content 409. A request with the same x-request-id but different content is a new post, so this layer does not protect a retry whose body changed; use Idempotency-Key for that. When both headers are sent, Idempotency-Key wins and x-request-id is ignored for matching.

The official SDKs generate a unique x-request-id per call, and the generated SDKs expose it as an explicit parameter: .XRequestId("<uuid>") in Go, x_request_id: in Ruby, a UUID argument in Java, Guid? in .NET, and x_request_id in Python, PHP and Rust. On a generic HTTP client, set a unique value per logical call or omit it.

A workflow tool that reuses one execution-level x-request-id or Idempotency-Key across several HTTP nodes (one value for the whole run, shared by 6 platform calls) makes every call after the first look like a retry of the first. With Idempotency-Key each returns the first post and nothing else is created. Generate a fresh UUID per node, or omit the header.

Layer 3: Content-hash dedup (24-hour window)

Independently of the headers, Zernio hashes (platform, accountId, content + media URLs) and rejects a match from the last 24 hours with 409. This catches the same content posted twice to the same account whatever the headers say. It only counts content that is scheduled, publishing or published: a post whose target failed or was cancelled without publishing does not block a new post with the same content. To post identical content again within 24 hours, change the fingerprint: the caption, the media or the account.

Order of evaluation

  1. Same Idempotency-Key seen in the last 24 hours: return the original post with 200 (or 409 idempotency_conflict while it is still processing). Nothing is created.
  2. Otherwise, same x-request-id and same content fingerprint in the last 24 hours: return the original post with 200.
  3. Otherwise, same content fingerprint seen in the last 24 hours: reject with 409 and details.existingPostId.
  4. Otherwise, create the post.

A 429 is also safe to retry after waiting Retry-After seconds; see rate limits.

Other write endpoints take Idempotency-Key

The layers above apply to post creation. These endpoints also accept an optional Idempotency-Key header (a client-generated unique key such as a UUID, up to 255 characters): create profile, send inbox message, reply to a post's comments, reply to a review, start a WhatsApp call, start a voice call, send SMS, boost a post, create an ad, create a messaging ad (POST /v1/ads/messaging, and the legacy POST /v1/ads/ctwa), create a call ad, and the create and duplicate endpoints for campaigns, ad sets and ads.

Send the key on the first attempt, then again on the retry:

curl -i -X POST "https://zernio.com/api/v1/profiles" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9f7c1f2e-6f0a-4a1f-9d2c-2b0a4f8e5c11" \
  -d '{ "name": "Acme Corp" }'

Response (201):

{
  "message": "Profile created successfully",
  "profile": { "_id": "66a1f0c2a4b9d3e8f1a2b3c4", "name": "Acme Corp" }
}

Run the same command again and no second profile is created: the stored response comes back with its original status and one extra header.

HTTP/2 201
Idempotent-Replayed: true
Content-Type: application/json

{ "message": "Profile created successfully", "profile": { "_id": "66a1f0c2a4b9d3e8f1a2b3c4", "name": "Acme Corp" } }

The same key with a different body returns 422; a key whose first request is still processing returns 409.

Two rules decide how you write the retry. Keys are retained for 24 hours and are scoped to your credential and to that exact path, so the same key sent to a different postId returns 422 instead of replaying the other post's response. And only a successful (2xx) response is stored for replay: a first attempt that fails releases the key, so the retry runs for real instead of replaying a failure.

So the header covers the "request succeeded, response was lost" case rather than every retry. After an ambiguous failure (a 5xx or a network timeout) the platform may already have accepted the message, and a failure after that point releases the key as well, so reconcile before you retry: list the conversation's messages, the post's comments or the review first, and treat an empty result as inconclusive rather than as proof nothing was sent.

The remaining write endpoints are PUT-style updates or cheap to check first: when in doubt, read before you write.

If it fails

A 409 on POST /v1/posts means this exact content already went to this account in the last 24 hours:

{
  "error": "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours.",
  "details": {
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "platform": "linkedin",
    "existingPostId": "65f1c0a9e2b5af0012ab34cd"
  }
}

Look up details.existingPostId with GET /v1/posts/{postId} and treat it as success, or surface it to the user. If the 409 came from a retry loop, send one Idempotency-Key per logical post and reuse it on the retry, so the retry hits Layer 1 instead.

A 409 with code: "idempotency_conflict" is different: the first request with that Idempotency-Key is still processing. Wait Retry-After seconds and send the same request again to get the original post.

Related

  • Create post: the full idempotency contract on the operation.
  • Error handling: the error envelope and which statuses to retry.
  • Rate limits: 429 and Retry-After.
  • Multi-tenant publishing: one key per customer request.
Was this page helpful?

Timezones & Scheduling

Schedule a post at a local wall-clock time with scheduledFor and timezone, and read back the UTC instant Zernio stores.

Post Lifecycle

Every post status, how a post moves between them, what you can do in each state, and which webhook fires on each transition.

On this page

First callHow it behavesLayer 1: Idempotency-Key (24-hour window)Layer 2: x-request-id retry detection (24-hour window)Layer 3: Content-hash dedup (24-hour window)Order of evaluationOther write endpoints take Idempotency-KeyIf it failsRelated