zhyper docs

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-Reset is 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-After is 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:

EndpointScopeWindow
createPostyour team and execution mode5 minutes
createProfileyour team24 hours
sendInboxMessagethe credential — the individual API key or panel user24 hours
sendSmsyour team and execution modenever expires
the 32 /v1/ads operations that document the parameteryour team and execution mode24 hours
createBlog, createBlogArticleyour team and execution mode24 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. skipDuplicateCheck turns 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:

  • listSmsBrands and listSmsCampaigns return records with page and totalRecords.
  • listSmsSenderIds returns senderIds with a meta object (pageNumber, pageSize, totalPages, totalResults).

listSmsMessages is not in this family — those rows are ours, so it carries the normal cursor envelope. See SMS.

On this page