MenuAuthentication

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.

PKCE (Node.js)
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.
Authorization URL
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=S256

2. 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.

Callback
https://your-app.example/oauth/callback?code=...&state=RANDOM_STATE
https://your-app.example/oauth/callback?error=access_denied&state=RANDOM_STATE

3. 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.

POST /api/oauth/token
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_...
200 OK
{
  "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.

Refresh
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_...
Nightly job (pseudo-code)
# 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 page

5. 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.

POST /api/oauth/revoke
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" }:

errorMeaning
invalid_requestA 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_clientUnknown client_id or wrong client_secret (401).
invalid_grantThe 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_typeOnly authorization_code and refresh_token are supported (400).
server_errorZenHost failed to answer. Retry later (500).