Connect an account (OAuth 2.0)
ZenHost uses the OAuth 2.0 authorization code flow with PKCE (S256, mandatory). The owner consents once in the browser; your server then lives on the refresh token, for example in a nightly cron job, without anyone signing in again.
1. Send the owner to ZenHost
Create a random code_verifier and a state, keep them server-side, and redirect the owner to the authorization URL. The redirect_uri must match one of your registered URIs exactly. Only the primary owner of an account can approve; they pick the account and the Properties your App may act on.
const crypto = require('node:crypto');
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Keep codeVerifier and state server-side until the owner comes back.https://app.zenhost.com/oauth/authorize
?response_type=code
&client_id=zh_ci_...
&redirect_uri=https://your-app.example/oauth/callback
&state=RANDOM_STATE
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S2562. Receive the code
ZenHost redirects back with a code and your state unchanged (check it). The code is single-use and expires after 10 minutes. If the owner refuses, you receive error=access_denied. An invalid request (unknown client, unregistered redirect URI, missing PKCE) is never redirected to you: ZenHost shows the error to the owner.
https://your-app.example/oauth/callback?code=...&state=RANDOM_STATE
https://your-app.example/oauth/callback?error=access_denied&state=RANDOM_STATE3. Exchange it for tokens
From your server, authenticate with your client secret, either in the body (client_secret_post) or with HTTP Basic (client_secret_basic). The body may be form-encoded or JSON.
curl -X POST https://app.zenhost.com/api/oauth/token \
-d grant_type=authorization_code \
-d code=CODE_FROM_THE_CALLBACK \
-d redirect_uri=https://your-app.example/oauth/callback \
-d code_verifier=CODE_VERIFIER \
-d client_id=zh_ci_... \
-d client_secret=zh_cs_...{
"access_token": "zh_at_...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "zh_rt_..."
}4. Run unattended on the refresh token
A refresh token expires after 90 days without use, and each refresh starts a new 90-day period: a job that refreshes at least that often never needs a new consent. When an account connects your App again, its older tokens stop working.
curl -X POST https://app.zenhost.com/api/oauth/token \
-u "zh_ci_...:zh_cs_..." \
-d grant_type=refresh_token \
-d refresh_token=zh_rt_...# Runs every night, months after a single consent.
tokens = store.load(account) # refresh_token saved at consent time
if tokens.access_expired():
response = POST https://app.zenhost.com/api/oauth/token
grant_type=refresh_token, refresh_token=tokens.refresh_token,
client_id, client_secret
if response.error == "invalid_grant":
alert("Ask the owner of", account, "to connect the App again")
stop
store.save(account, response) # save BEFORE using: the old refresh token is now dead
for page in every page of GET /api/v1/properties: # see "Every Property, 100 at a time"
restrictions = GET /api/v1/restrictions?property_id[]=...&from=...&to=...
batch = POST /api/v1/restriction-batches # with an Idempotency-Key per run and page5. Revoke
Revoke a token with RFC 7009, authenticated like the token endpoint: once your client is authenticated the answer is always 200, even for an unknown token. Revoking the last live refresh token of the current consent ends the App Connection, exactly as if the owner had revoked it: you receive app_connection.revoked, unsent Restriction changes are cancelled, and only a new consent brings the account back. Revoking an access token, or a refresh token from before the account connected again, only kills that token.
curl -X POST https://app.zenhost.com/api/oauth/revoke \
-u "zh_ci_...:zh_cs_..." \
-d token=zh_rt_...Token formats and OAuth errors
Tokens are opaque and prefixed so secret scanners can spot a leak: zh_at_ (access), zh_rt_ (refresh), zh_cs_ (client secret), whsec_ (webhook signing secret). The token and revocation endpoints answer standard OAuth errors as { "error", "error_description" }:
| error | Meaning |
|---|---|
invalid_request | A required parameter is missing, the body is neither form-encoded nor JSON, or the client authenticates twice (Basic and client_secret) or with a mismatched client_id (400). |
invalid_client | Unknown client_id or wrong client_secret (401). |
invalid_grant | The code or refresh token is unknown, expired, already used, issued to another App, or superseded by a newer consent; or redirect_uri / code_verifier does not match (400). Ask the owner to connect again. |
unsupported_grant_type | Only authorization_code and refresh_token are supported (400). |
server_error | ZenHost failed to answer. Retry later (500). |