zhyper docs

Authentication

zhyper accepts two kinds of credential. They are not interchangeable, and a single request may carry exactly one.

CredentialHeaderWho uses itMode
API keyAuthorization: Bearer sk_...Your serverFixed by the key
Panel sessionzhyper_session cookie, or Authorization: Bearer zs_...The panel UIChosen per request

Sending both returns 400 invalid_request_error / ambiguous_credentials. We do not silently pick one: preferring the cookie would let a cross-site request override the key your code deliberately sent, and preferring the header would let a signed-in user act with a key they never chose. Either way the request would be carried out under an identity nobody selected.

Creating an account

POST /v1/auth/register creates a user, a team, and the owner membership that joins them. It has two response shapes, and which one you get is a property of the deployment rather than of your request:

Email deliveryStatusBodySession
Configured202{"verification":"pending"}Not issued
Not configured201The identity objectCookie set

Write your client against both. The deployment decides once, at boot, from AUTH_EMAIL_DELIVERY; it never varies per request.

Whether the endpoint is open at all is also a deployment property. A production deployment answers 503 registration_unavailable to every registration attempt unless the operator has enabled public registration together with a real mailbox-verifying delivery provider. The response does not depend on the address, so a closed deployment cannot be used to probe for accounts. In a closed deployment accounts are created through team invitations (POST /v1/team/invitations/accept), which this gate does not affect, and signing in keeps working for existing accounts.

When delivery is configured the 202 is uniform. An address that already has an account and one that does not produce the same status code and the same body — the difference appears only in the mailbox, where one receives a verification link and the other a notice that the address is already registered. This is deliberate: an endpoint that answered 409 for taken addresses would be a way to test whether someone has an account here.

Verification consumes a token, not a link:

curl -X POST https://api.zhyper.dev/v1/auth/verify-email \
  -H "Content-Type: application/json" \
  -d '{"token":"zv_..."}'

The token is single-use and expires 24 hours after registration. Missing, expired, and already-consumed tokens all return the same 400 verification_token_invalid — again so the response cannot confirm that a valid token exists.

A lapsed link repairs itself on the next sign-in. If the 24 hours lapse, sign in again with the same address and password: a fresh verification link is issued as part of that request. There is no separate resend endpoint, and re-registering the same address still returns the "already registered" notice rather than a new link — a registration attempt must never be able to mint a token for an existing account.

Two consequences worth designing around. The reissue happens after the password is checked, so it is not a way to trigger mail for an address you do not control. And the response body and status code are identical whether a link was sent or suppressed by the per-address throttle, so your client cannot detect which happened — expect users to check their inbox rather than reading the response.

Rate limits on the identity endpoints

The endpoints that create or consume an identity are limited per client IP, independently of any per-account counter. The values are fixed in code, not configurable per deployment — a threshold that decides a security behaviour should not be silently removable by an environment variable.

EndpointLimit
POST /v1/auth/register20 / 15 min
POST /v1/auth/login60 / 15 min
POST /v1/auth/verify-email60 / 15 min
POST /v1/team/invitations/accept20 / 15 min
GET /v1/connect/pending-data60 / 15 min

The last row is the odd one out and is listed here because there is nowhere else it would be found: it is not an identity endpoint but a single-use OAuth capability read, and it is limited on the same per-IP counter for the same reason — it is reachable without a credential. A polling loop around it will hit 429 well before a browser flow ever would.

Login is also counted per email address. The per-IP ceiling exists because that per-address counter does nothing against password spraying, where each address is tried exactly once.

The ceilings are generous on purpose: an entire office, campus, or corporate VPN reaches us from a single source address, so a tight limit would cut off legitimate users at the start of a working day without slowing an attacker who has more than one address.

Rejected credentials are counted too

The table above covers the endpoints you can reach without a credential. A second, separate cage covers the opposite case: a request that does carry a credential, to an endpoint that requires one, and is rejected with 401.

It is a distinct counter with a distinct reason to exist. Rejecting a credential is not free, and the cost is paid before we know who you are: an API key costs a hash and one indexed lookup, and a panel session token costs a three-table join. Without a ceiling, a single address could keep us doing that work indefinitely.

  • What it counts. Only responses that came back 401 — an unknown, revoked, malformed or expired sk_ key, or a zs_ session token that no longer resolves. A request that authenticates and is then refused for some other reason, such as 403 for a missing permission, is not counted here.
  • The ceiling. 200 failed attempts per client IP, in a rolling window of 15 minutes. It is fixed in code rather than read from configuration, for the same reason as the table above.
  • What happens at the ceiling. Further requests from that address are answered 429 — including requests that carry a valid credential. The bucket is keyed by address, not by credential, because a per-credential bucket would hand an attacker a fresh bucket on every guess and limit nothing.

That last point is the one worth planning around, and it is why the ceiling is set where it is rather than lower. A client that retries a rotated key once a second would exhaust a tight ceiling in seconds and take its own working integration down with it. 200 attempts in 15 minutes leaves ordinary retry behaviour far below the line while still bounding the work one address can cause.

Being caged does not lock you out of the product. POST /v1/auth/login is one of the credential-free endpoints in the table above, and it is counted on its own separate counter. So a person behind a caged address can still log in and obtain a fresh session; only the rejected-credential path is closed. If your server is the one being caged, the fix is to stop the retry loop, then revoke the failing key and issue a new one — see Rate limits for how to read Retry-After on the 429.

API keys

curl https://api.zhyper.dev/v1/profiles \
  -H "Authorization: Bearer $ZHYPER_API_KEY"

A key carries three things that cannot be widened at request time:

  • Mode. sk_test_ is always test, sk_ is always live. See Test mode.
  • Scope. A key is either unrestricted or limited to a list of profile ids. Anything outside the list returns 404, indistinguishable from a non-existent id.
  • Read-only. A read-only key is refused on every write with 403.

A key carries no permission list of its own. Permissions are not stored per key, so there is nothing on a key to grant, revoke or widen: every key that is not read-only carries the same set, and a read-only key carries the read half of that set. The practical consequence is worth stating plainly, because it is the first question people ask when a surface moves behind a new permission — as the WhatsApp endpoints just did. There is nothing to add to a key you already issued. It keeps working.

A key can never reach the control plane. Creating keys, managing billing, deleting the team, and reading or changing team membership are panel-only. The ceiling holds no matter who created the key — an owner's key has exactly the same reach as a member's.

Keys are shown once at creation. We store only a SHA-256 digest, so we could not show it again even if we wanted to.

Checking a credential without using it

GET /v1/auth/verify (verifyCredential) answers one question — is the credential on this request valid? — and reads no product data. It returns valid, authType, userId for a session, and scope: null. The published authType enum carries three values — api_key, oauth, session — but this deployment only ever emits the first and third; oauth is reserved and nothing produces it today, so do not write a branch that waits for it.

As of 2026-08-31 it asks only that the credential resolve. It used to require profiles:read, which made it the one endpoint a valid credential could be refused by while being perfectly valid: a signed-in billing_admin carries billing:manage and nothing else, so "am I signed in?" answered 403. A narrowly scoped key had the same problem. Any resolved identity now passes, and a health check written against this endpoint no longer needs a permission it does not otherwise want.

It is not a scope report. scope is always null, so it will not tell you which profiles a key can reach — read GET /v1/profiles for that, where an out-of-scope id is simply absent.

Panel sessions

The panel signs in against POST /v1/auth/login and receives an httpOnly, SameSite=Lax cookie. Session tokens start with zs_; like keys, only their digest is stored.

A session carries the user's role — one of owner, admin, billing_admin, member, read_only — read from team membership at every request. Removing someone from the team therefore ends their sessions immediately — there is no separate revocation step that could be forgotten.

Cookie-authenticated writes are additionally checked against Sec-Fetch-Site/Origin. Requests using Authorization are exempt, because a browser never attaches that header on its own and such a request cannot be a CSRF.

Roles

There are five roles, not four. billing_admin is easy to miss because it is the only one that carries no data-plane permission at all.

Permissionowneradminbilling_adminmemberread_only
Read profiles, accounts, posts, inbox, contacts, workflows, ads, WhatsApp, webhooks, logs
Write profiles, accounts, posts, inbox, contacts, workflows, ads, WhatsApp, webhooks
Read team members
Read the audit log
Invite and manage members
Create API keys
Manage billing
Remove members / delete team

Two rows in that table were wrong here until 2026-08-31, and both in the direction that matters. Managing billing is not owner-onlyadmin carries it too, and billing_admin carries it and nothing else. And the read and write rows omitted contacts, workflows and ads, which have been their own permission pairs for some weeks; a read_only member has been able to read contacts, broadcasts, sequences, comment automations and the whole ads surface that entire time.

billing_admin is a genuinely narrow role. It cannot list team members, cannot read a profile, cannot see a post. If you invite someone to pay the bill, that is all they get. Note the consequence in the other direction too: it is the one role for which "can this person read anything at all" is answered no, so a panel screen that assumes every signed-in user can load a profile list will break for them.

Roles are set when you invite someone (POST /v1/team/invitations takes owner, admin, billing_admin, member or read_only) and changed with PATCH /v1/team/members/{userId}. The invite-link surface (POST /v1/invite/tokens) uses a slightly different vocabulary: admin, member, billing_admin and viewer, where viewer is the name for read_only. Same five roles, two spellings; the one to hand to your own UI is the team one, because that is what a member's role field comes back as.

Inbox permissions (inbox:read, inbox:write) cover the /v1/inbox surface — conversations, messages, comments and review replies. They sit exactly where posts:* sits, so the endpoints reachable by a given role did not change when they were introduced; only the permission name did.

Reading the audit log is deliberately narrower than reading request logs. The audit log answers "who changed permissions", so the set of people who can read it cannot be wider than the set who can change them.

An admin cannot grant or modify the owner role, and the last owner can be neither demoted nor removed — a team with zero owners cannot be administered back into a working state.

WhatsApp has its own pair

whatsapp:read and whatsapp:write cover /v1/whatsapp/** and /v1/connect/whatsapp/** — templates, phone numbers, flows, groups, calling, the business profile, block lists, media, the sandbox and the WhatsApp connect flow. Until now those endpoints sat behind accounts:* or posts:*, depending on which one you happened to be calling.

Nothing you can call today stopped working. The new pair was placed in exactly the roles that already carried accounts:*, and whatsapp:read is part of the read-only set, so a read-only key keeps every WhatsApp read it had. Combined with the fact that keys carry no permission list of their own, the answer to "must I re-issue my key" is no.

The split exists because the two surfaces are not decisions of the same size. accounts:write reads as "manage the accounts I have connected". The WhatsApp surface also buys phone numbers, uploads KYC documents, deletes numbers and places calls. A client that asked for the first was being handed the second, and you could not see that by reading either name.

Two boundaries are worth stating, because the word "WhatsApp" appears on both sides of them:

  • Publishing to a WhatsApp account stays posts:*. WhatsApp is also a platform you can target from /v1/posts, and that path did not move. What moved is the /v1/whatsapp surface, not the channel.
  • SMS stays accounts:*. A sending number belongs to an account, and there is no single pair that covers both surfaces — see SMS.

On this page