Rate limits and idempotency
Rate limits
Exceeding a limit returns 429 with type: "rate_limit_error" and a
Retry-After header. Honour that header rather than a fixed sleep — it is the
number of seconds until your usage actually drops back under the ceiling, which
your client has no way to compute.
Retry-After and X-RateLimit-Reset do not answer the same question
This is the distinction most clients get wrong, and getting it wrong costs you a
second 429:
X-RateLimit-Resetis an absolute Unix time in seconds, marking when the counting period turns over. It is not the seconds remaining — subtract the current time for that — and it is not the moment your capacity comes back.Retry-Afteris a number of seconds to wait, and it is the one that tracks recovery.
They differ because your usage is measured against a trailing period rather than
tallied and dropped at a boundary: the ceiling is not handed back to you all at
once, and traffic you sent before the turnover keeps weighing on it for a while
afterwards. After a 429, the instant Retry-After points at is normally
later than X-RateLimit-Reset, so retrying at Reset earns you a second
429. Wait for Retry-After.
The practical consequence, if you are used to a per-minute counter that empties on the minute: you cannot save up a burst by sending your whole allowance just before a boundary and the same allowance again just after. That worked once, and the doubled spike showed up as latency for whoever else was sharing the pool at that instant. As of 2026-08-31 it does not work — the second half is refused. Your sustained rate is unchanged; only the spike is gone.
A rejected request is not held against you
Counting happens only when a request is let through, so a 429 does not make
your situation worse. Retrying too early costs you nothing but another 429: it
consumes no capacity and does not push your recovery further away. This matters
if you run several workers against one credential — the ones that arrive early
are not silently digging a hole for the one that arrives on time.
It also means Retry-After stays trustworthy under repeated retries, rather
than describing a moment your own retries have already moved.
One 429 arrives without Retry-After
Measured against the source on 2026-08-31, exactly one 429 in this API omits
it: the per-address sign-in lock, 429 too_many_login_attempts
(Authentication). Every other 429 sets the header —
the request throttler described above, and outbound_rate_limited, which is
raised when the outbound budget we hold for a platform or carrier is exhausted
rather than when your own ceiling is. So read Retry-After as advisory rather
than guaranteed: keep a backoff of your own behind it.
The SDK does exactly that: 429 and 5xx are retried with exponential backoff
and jitter, and Retry-After overrides the computed delay. 4xx is not
retried, because repeating it produces the same answer and only adds load.
That override has a ceiling of its own, though, and it is low. Measured
2026-08-31: the SDK clamps any single wait to 20 seconds and retries twice by
default. A Retry-After longer than that gets clamped — the identity endpoints
count over 15-minute windows, so theirs can be far longer — and the retries run
out before your capacity returns. Catch the 429 that comes back out of the
SDK and schedule the work: raising maxRetries buys you more attempts spaced 20
seconds apart, not one longer wait.
export function clientWithRetries(apiKey: string, baseUrl: string): ZhyperClient {
// `maxRetries` YALNIZ 429 ve 5xx icin kosar; 4xx yeniden denenmez, cunku ayni
// cevabi uretir ve sadece yuk ekler. Varsayilan 2'dir.
return new ZhyperClient({ apiKey, baseUrl, maxRetries: 4 });
}
Jitter matters at scale: without it, every client that hits a limit at the same moment comes back at the same moment.
The panel does not eat your API key's quota
As of 2026-08-25 a session in the zhyper panel is counted in its own bucket,
separate from your API keys, even though both belong to the same team. Before that
they shared one counter, so browsing the panel could exhaust the ceiling your
integration was relying on — and the 429 would surface on the next page rather
than where it started.
The number you see in X-RateLimit-Limit has not changed: each bucket carries your
team's tier ceiling. What changed is that there are now two buckets. Every header
you receive describes the bucket that applied to your request — a request is
never counted in both — so X-RateLimit-Remaining means exactly what it says for
the credential that asked.
Idempotency
Send Idempotency-Key on a state-changing request that supports it, and if the
same key arrives again you get the original result instead of a second side
effect. Which requests support it is not "all of them" — the list is below,
and so are the scope and lifetime, which differ per endpoint.
The SDK generates one per request automatically and — this is the important part — reuses it across retries. A fresh key per attempt would make the server treat the retry as a new request, and the work could happen twice. Retrying is safe precisely because the key stays fixed.
Supply your own when a retry may span process restarts:
export async function createProfileOnce(
zhyper: ZhyperClient,
input: { jobId: string; name: string; timezone: string },
): Promise<Schemas['ProfileDto']> {
// Anahtari KENDI is biriminden turet, govdeden degil. Iki gercekten ayri
// istek ayni govdeyi tasiyabilir; govdeyi hash'lemek onlari sessizce tek bir
// istege cokertirdi.
return zhyper.request<Schemas['ProfileDto']>('createProfile', {
body: { name: input.name, timezone: input.timezone },
idempotencyKey: `profile-${input.jobId}`,
});
}
Derive the key from your unit of work, not from the payload. Two genuinely distinct requests can carry identical bodies, and hashing the body would silently collapse them into one.
Replaying the same key returns the original response rather than doing the work again, and the test suite proves it:
export async function replayReturnsTheSameProfile(
zhyper: ZhyperClient,
input: { jobId: string; name: string; timezone: string },
): Promise<{ first: string; second: string }> {
// AYNI anahtarla ikinci cagri IKINCI bir profil yaratmaz: ilkinin cevabini
// dondurur. SDK anahtari yeniden deneme dongusunun DISINDA uretir, yani kendi
// yeniden denemeleri de bu garantiden yararlanir.
const first = await createProfileOnce(zhyper, input);
const second = await createProfileOnce(zhyper, input);
return { first: first.id, second: second.id };
}
Scope and lifetime differ per endpoint
There is no single answer here, and assuming one will bite you. Measured 2026-08-31:
| Endpoint | Scope | Window |
|---|---|---|
createPost | your team and execution mode | 5 minutes |
createProfile | your team | 24 hours |
sendInboxMessage | the credential — the individual API key or panel user | 24 hours |
sendSms | your team and execution mode | never expires |
the 32 /v1/ads operations that document the parameter | your team and execution mode | 24 hours |
createBlog, createBlogArticle | your team and execution mode | 24 hours |
initiateWhatsAppCall | — | — |
Two of these will catch you out. createPost's window is five minutes, not
24 hours — long enough to absorb a retry, far too short to deduplicate a nightly
job. And sendInboxMessage is credential-scoped rather than team-scoped: two
API keys on the same team using the same key value do not replay each other.
sendSms has no window at all. Its key is a column on the message row and is
unique per team and mode for as long as that row exists, so a key you used a
year ago still replays its original message today. That is deliberate: a
message row is never deleted, so there is no moment at which forgetting the key
would be safe.
Reusing a key with a different body is always a 409 idempotency_key_reused,
never a silent replay of the wrong response.
initiateWhatsAppCall is different in kind. It does not replay locally at all —
the header is forwarded to WhatsApp, so the guarantee, the scope and the window
are WhatsApp's, not ours. Read their documentation for what it promises.
Which endpoints honour Idempotency-Key
Not all of them, and the distinction is load-bearing. The header is read by the
endpoints that document an Idempotency-Key parameter in the
API reference — measured against openapi.json on
2026-08-31, 39 operations: createPost, createProfile,
sendInboxMessage, sendSms, initiateWhatsAppCall, createBlog,
createBlogArticle, and 32 operations under /v1/ads, which share one replay
table scoped to your team and mode with a 24-hour window.
Not every /v1/ads write, though, and this page claimed otherwise twice
before it was measured. There are 53 POST/PUT/PATCH/DELETE operations
under /v1/ads and only 32 of them read the header. The 21 that do not include
six that are POST-shaped reads (bid pricing, supply forecast, keyword ideas
and historical metrics, ad preview, reach estimate) — and fifteen that really do
mutate: the audience membership and company-list writes, updateAdAccount, the
value-rule-set family, the lead-form create and archive, the high-demand period
create, and the reach-and-frequency prediction, reserve and cancel calls. Retry
those the way you would retry any endpoint with no replay guarantee: check
before you send again.
The count moved twice, and the page records both moves rather than quietly
swapping a digit. It said "four endpoints" and stopped, which read as an
exhaustive list; on 2026-08-25 that was corrected to 29; on 2026-08-31 the same
measurement returned 39. The rule — look for the parameter in the API
reference — is the load-bearing part, and it is exactly why the enumeration and
the "every write under /v1/ads" shorthand both kept going stale behind it. The
path prefix is not the signal. The parameter is.
Seven of them require the header
The paragraph above says "the header is read by", which is true and incomplete. On seven operations the key is not optional — omit it and the request is rejected before anything happens:
createBlog, createBlogArticle, uploadAdVideo, adjustConversions,
attachCampaignAssets, sendConversions, createTestLead.
They are the writes whose provider call cannot be safely repeated on a guess, so
the API refuses to make one without a key to reconcile against. The SDK sends a
key on every non-GET request, so an SDK caller meets this for free; a raw
curl does not.
Those same operations can answer a third way. If the provider may have committed
the write but its response was lost, a retry with the same key returns
409 idempotency_key_outcome_unknown rather than either replaying a result we
do not have or starting a second create. That is not a transient error to retry
around — it means go and look at the provider, because only the provider knows
whether the first attempt landed.
createPost accepts it as of 2026-08-12. Before that it replayed only on
x-request-id, a header this SDK does not send, so passing idempotencyKey
did nothing there and a retry could create a second post. If you built a
workaround, you can drop it — but the change is additive, so keeping it is
safe: x-request-id still works, and is still used when no Idempotency-Key
is sent.
Posts carry a second, independent protection that behaves differently. A
duplicate-content fence scoped to your team, mode and target account
rejects a repeat with 409 duplicate_content rather than replaying it:
export async function publishTwice(
zhyper: ZhyperClient,
input: {
content: string;
targets: PostTarget[];
scheduledFor: string;
timezone: string;
},
): Promise<{ firstId: string; secondOutcome: string }> {
// `createPost` artik `Idempotency-Key`i okuyor -- ama bu ornek onu BILEREK
// GONDERMIYOR, cunku gosterdigi sey IKINCI korumadir: yinelenen-icerik citi.
//
// Cit anahtardan BAGIMSIZ calisir ve farkli davranir: sessizce ilk cevabi
// dondurmez, 409 `duplicate_content` ile REDDEDER. Yani taze bir anahtarla
// gelen GERCEKTEN yeni bir istek bile ayni icerigi tasiyorsa reddedilir.
const body: CreatePostBody = {
content: input.content,
platforms: input.targets,
scheduledFor: input.scheduledFor,
timezone: input.timezone,
};
const first = await zhyper.request<Schemas['CreatePostResponseDto']>('createPost', {
body,
});
try {
await zhyper.request<Schemas['CreatePostResponseDto']>('createPost', { body });
return { firstId: first.post.id, secondOutcome: 'created-again' };
} catch (error) {
// `ZhyperApiError` kodu ALANDA tasir; govdeyi yeniden cozmeye gerek yok.
const code = error instanceof ZhyperApiError ? error.code : 'unknown';
return { firstId: first.post.id, secondOutcome: code };
}
}
Two consequences:
- Handle the
409. It fires on identical content even when the request is genuinely new and carries a fresh key.skipDuplicateCheckturns it off when you mean to post the same text twice. - Drafts are not fenced. The check runs only for posts that are actually scheduled or published, so two identical drafts sent with different keys are two drafts. With the same key, idempotency catches it.
Pagination
List endpoints return:
{
"data": [ /* ... */ ],
"hasMore": true,
"nextCursor": "eyJuYW1lIjoiQWNtZSIsImlkIjoiLi4uIn0"
}
Pass nextCursor back as the cursor query parameter. Stop when hasMore is
false or nextCursor is null — the SDK's paginate stops on either, so a
server bug cannot turn into an infinite loop on your side.
Cursors are opaque. They encode a keyset position, not an offset, so pages stay
stable while rows are inserted. Do not construct or mutate them; a cursor that
did not come from us is rejected with 400 invalid_cursor.
Offset pagination has a ceiling. A few list endpoints also accept skip
alongside limit — contacts, profiles, broadcasts and their recipients,
sequences and their enrollments, comment automations and their logs, and
workflows with their executions, execution events and versions. On all of them
skip must not exceed 100000; a larger value is 400 invalid_skip with the
message skip must not exceed 100000. Webhook log listing is stricter and
unchanged: its skip stops at 10000 and answers 400 invalid_pagination.
The ceiling is not arbitrary. An offset re-counts every row it skips, so deep offsets cost you latency and cost every other tenant on the shared pool the same work; and past the first few pages the answer is wrong anyway, because rows inserted while you page shift the window and make you repeat or miss records. Beyond that depth the cursor is both cheaper and correct, so that is where the offset stops.
One endpoint is deliberately not paginated. GET /v1/platforms returns every
platform in a single response with no envelope, because its length is fixed by our
code — one entry per platform we support — and not by your data. Nothing you do can
grow it, so a cursor would be ceremony. Every other list endpoint carries the
envelope above, including the team and credential management ones — with one
family of exceptions worth stating precisely, because it is not one shape but
two. The SMS registry lists read the carrier directly and hand back whatever
that registry returns, since zhyper keeps no local copy of those records:
listSmsBrandsandlistSmsCampaignsreturnrecordswithpageandtotalRecords.listSmsSenderIdsreturnssenderIdswith ametaobject (pageNumber,pageSize,totalPages,totalResults).
listSmsMessages is not in this family — those rows are ours, so it carries
the normal cursor envelope. See SMS.