> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenzap.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Call the Zenzap API with your bot's credentials

This page assumes you've already created a bot in the Zenzap console. If not, start at [Setup](/api-reference/setup-steps) — that's where you pick a credential type.

Open the tab that matches your bot's credential type.

<Tabs>
  <Tab title="Static API key">
    Every request carries three headers:

    ```
    Authorization: Bearer YOUR_API_KEY
    X-Signature: <HMAC-SHA256 hex-encoded signature>
    X-Timestamp: <Unix timestamp in milliseconds>
    ```

    The signature payload differs by HTTP method:

    * **POST/PUT/PATCH/DELETE**: sign `{timestamp}.{body}`
    * **GET**: sign `{timestamp}.{uri}` (full path + query string)

    For `multipart/form-data` requests, sign the exact raw request body bytes with a timestamp prefix: `{timestamp}.<raw-bytes>`.

    Requests with timestamps older than 5 minutes are rejected.

    ### How to calculate

    1. Get the current Unix timestamp in milliseconds.
    2. Build the payload:
       * **POST/PUT/PATCH/DELETE**: `{timestamp}.{raw-body}`
       * **GET**: `{timestamp}.{uri}` (for example `/v2/members?limit=10&offset=0`)
    3. Calculate HMAC-SHA256 of the payload using your API secret.
    4. Hex-encode the digest (64 lowercase chars).
    5. Send both `X-Signature` and `X-Timestamp`.

    ### Example — POST (Python)

    ```python theme={"theme":"github-dark"}
    import json
    import hmac
    import hashlib
    import time
    import requests

    def create_topic(name: str, members: list[str]) -> dict:
        body = {"name": name, "members": members}
        body_json = json.dumps(body, separators=(",", ":"))
        timestamp = int(time.time() * 1000)

        signature_payload = f"{timestamp}.{body_json}"
        signature = hmac.new(
            API_SECRET.encode(),
            signature_payload.encode(),
            hashlib.sha256,
        ).hexdigest()

        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "X-Signature": signature,
            "X-Timestamp": str(timestamp),
        }

        response = requests.post(f"{BASE_URL}/v2/topics", headers=headers, data=body_json)
        response.raise_for_status()
        return response.json()
    ```

    ### Example — GET (Python)

    ```python theme={"theme":"github-dark"}
    import hmac
    import hashlib
    import time
    import requests

    def get_topic(topic_id: str) -> dict:
        uri_path = f"/v2/topics/{topic_id}"
        timestamp = int(time.time() * 1000)

        signature_payload = f"{timestamp}.{uri_path}"
        signature = hmac.new(
            API_SECRET.encode(),
            signature_payload.encode(),
            hashlib.sha256,
        ).hexdigest()

        headers = {
            "Authorization": f"Bearer {API_KEY}",
            "X-Signature": signature,
            "X-Timestamp": str(timestamp),
        }

        response = requests.get(f"{BASE_URL}/v2/topics/{topic_id}", headers=headers)
        response.raise_for_status()
        return response.json()
    ```

    <Note>
      Our API documentation tools cannot automatically generate HMAC signatures. Calculate the signature manually or use a tool like Postman with pre-request scripts.
    </Note>
  </Tab>

  <Tab title="OAuth 2.0">
    Exchange your `clientId` + `clientSecret` for a short-lived bearer access token, then call the API with that token. Only the `client_credentials` grant is supported.

    ### Token endpoint

    Send the token request parameters as URL-encoded form fields, not as JSON or a raw request body.

    ```http theme={"theme":"github-dark"}
    POST /oauth/token HTTP/1.1
    Host: api.zenzap.co
    Content-Type: application/x-www-form-urlencoded

    grant_type=client_credentials
    &client_id=<clientId>
    &client_secret=<clientSecret>
    &scope=channel:list+message:send
    ```

    `scope` is optional. If omitted, the token receives every scope configured on the bot. Pass a space-separated subset to down-scope.

    HTTP Basic Auth is also accepted — send `Authorization: Basic base64(clientId:clientSecret)` and omit `client_id` + `client_secret` from the form body.

    #### Success response

    ```json theme={"theme":"github-dark"}
    {
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "channel:list message:send"
    }
    ```

    * `expires_in` is the token lifetime in seconds (default 3600 = 1 hour).
    * There is **no refresh token**. When the access token expires, call `/oauth/token` again.

    #### Error response

    Errors follow RFC 6749 §5.2:

    ```json theme={"theme":"github-dark"}
    {
      "error": "invalid_grant",
      "error_description": "invalid client credentials or scopes"
    }
    ```

    | Error code               | Meaning                                                       |
    | ------------------------ | ------------------------------------------------------------- |
    | `invalid_request`        | Malformed request body or missing required field              |
    | `invalid_client`         | `client_id` / `client_secret` missing or wrong                |
    | `invalid_grant`          | Credentials or requested scopes are not valid for this client |
    | `unsupported_grant_type` | Only `client_credentials` is supported                        |

    ### Calling the API

    Pass the JWT as a Bearer token on every request:

    ```http theme={"theme":"github-dark"}
    GET /v2/topics HTTP/1.1
    Host: api.zenzap.co
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
    ```

    Do **not** include `X-Signature` or `X-Timestamp` on OAuth requests — those headers are only used by the static-API-key flow.

    If the token is missing, expired, revoked, or its bot has been deactivated, the API returns `401 Unauthorized` with an RFC 6750 `WWW-Authenticate` challenge:

    ```
    WWW-Authenticate: Bearer realm="zenzap", error="invalid_token", error_description="Invalid Bearer token"
    ```

    If the token is valid but lacks the scope required by the endpoint, the API returns `403 Forbidden`:

    ```
    WWW-Authenticate: Bearer realm="zenzap", error="insufficient_scope", scope="message:send"
    ```

    ### End-to-end example (Python)

    ```python theme={"theme":"github-dark"}
    import os
    import requests

    BASE_URL = "https://api.zenzap.co"
    CLIENT_ID = os.environ["ZENZAP_CLIENT_ID"]
    CLIENT_SECRET = os.environ["ZENZAP_CLIENT_SECRET"]


    def get_access_token() -> str:
        """Exchange client credentials for a short-lived bearer token."""
        resp = requests.post(
            f"{BASE_URL}/oauth/token",
            data={
                "grant_type": "client_credentials",
                "client_id": CLIENT_ID,
                "client_secret": CLIENT_SECRET,
                "scope": "channel:list message:send",
            },
        )
        resp.raise_for_status()
        return resp.json()["access_token"]


    def send_message(token: str, topic_id: str, text: str) -> dict:
        resp = requests.post(
            f"{BASE_URL}/v2/messages",
            headers={"Authorization": f"Bearer {token}"},
            json={"topicId": topic_id, "text": text},
        )
        resp.raise_for_status()
        return resp.json()


    if __name__ == "__main__":
        token = get_access_token()
        send_message(token, "550e8400-e29b-41d4-a716-446655440000", "Hello from OAuth!")
    ```

    In production, cache the access token for slightly less than `expires_in` and re-mint on demand rather than on every request.

    ### Rotating the `clientSecret`

    Rotate from the bot's detail screen in the Zenzap console. After rotation:

    * The new `clientSecret` is returned **once** in the rotate response. Save it immediately.
    * Tokens minted with the old secret continue to work until they expire (up to 1 h).
    * Future `/oauth/token` calls with the old secret return `invalid_grant`.
  </Tab>
</Tabs>

## OAuth scopes

OAuth bots are authorized by scope, not by `read` / `write`. Each `/v2/*` endpoint requires a specific scope — the bot must be granted that scope at creation, and the access token must include it.

| Scope            | Grants access to                                                                                                                                     |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channel:list`   | `GET /v2/topics`                                                                                                                                     |
| `channel:read`   | `GET /v2/topics/{topicId}`, `GET /v2/topics/external/{externalId}`                                                                                   |
| `channel:write`  | `POST /v2/topics`, `PATCH /v2/topics/{topicId}`, `POST/DELETE /v2/topics/{topicId}/members`                                                          |
| `message:read`   | `GET /v2/messages/{messageId}`, `GET /v2/topics/{topicId}/messages`                                                                                  |
| `message:send`   | `POST /v2/messages`                                                                                                                                  |
| `message:write`  | `PATCH /v2/messages/{messageId}`, `DELETE /v2/messages/{messageId}`, `POST /v2/messages/{messageId}/delivered`, `POST /v2/messages/{messageId}/read` |
| `reaction:write` | `POST /v2/messages/{messageId}/reactions`, `DELETE /v2/messages/{messageId}/reactions/{reactionId}`                                                  |
| `task:read`      | `GET /v2/tasks`, `GET /v2/tasks/{taskId}`                                                                                                            |
| `task:write`     | `POST /v2/tasks`, `PATCH /v2/tasks/{taskId}`, `DELETE /v2/tasks/{taskId}`                                                                            |
| `poll:write`     | `POST /v2/polls`, `POST/DELETE /v2/polls/{pollId}/votes/...`                                                                                         |
| `member:read`    | `GET /v2/members`, `GET /v2/members/me`                                                                                                              |
| `updates:read`   | `GET /v2/updates`                                                                                                                                    |

A token may carry multiple scopes. Request the minimum set you need — narrower scopes limit the blast radius if the token is ever leaked.
