Authentication
zhyper accepts two kinds of credential. They are not interchangeable, and a single request may carry exactly one.
| Credential | Header | Who uses it | Mode |
|---|---|---|---|
| API key | Authorization: Bearer sk_... | Your server | Fixed by the key |
| Panel session | zhyper_session cookie, or Authorization: Bearer zs_... | The panel UI | Chosen 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 delivery | Status | Body | Session |
|---|---|---|---|
| Configured | 202 | {"verification":"pending"} | Not issued |
| Not configured | 201 | The identity object | Cookie 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.
| Endpoint | Limit |
|---|---|
POST /v1/auth/register | 20 / 15 min |
POST /v1/auth/login | 60 / 15 min |
POST /v1/auth/verify-email | 60 / 15 min |
POST /v1/team/invitations/accept | 20 / 15 min |
GET /v1/connect/pending-data | 60 / 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 expiredsk_key, or azs_session token that no longer resolves. A request that authenticates and is then refused for some other reason, such as403for 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.
| Permission | owner | admin | billing_admin | member | read_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-only — admin 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/whatsappsurface, 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.
Quickstart
zhyper publishes to social platforms through one API. This page gets you from an empty account to a profile you can attach social accounts to.
Test mode
Test mode is a full copy of the API that never reaches a real social platform. You can connect accounts, schedule posts, watch them publish and receive webhooks — end to end — with