<!-- Generated from the canonical Teloring documentation source. Do not edit this file. -->

Canonical page: https://docs.teloring.com/api/guide-authentication
Last modified: 2026-08-20T20:49:57.000Z

# Authentication

Teloring uses **OAuth 2.0 client credentials**. You hold a long-lived client id
and secret; you exchange them for a short-lived access token; you send that
token on every request.

```
client id + secret  ──POST /v1/oauth/token──▶  access token (1 hour)
                                                     │
                            Authorization: Bearer ────┘
```

The split is the point. A leaked access token expires by itself within the hour.
A leaked secret is revoked in one click, without you redeploying anything.

## Creating a credential

**Settings → API → New credential.** You choose four things:

| | |
| --- | --- |
| **Name** | For you. It shows up in the audit log next to everything this credential does, so name it after the integration, not after a person. |
| **Expiration** | A date, or unlimited. A date is worth setting for anything temporary — a migration script, a contractor's integration. |
| **Scopes** | Which feature areas it may reach. See [Scopes](./guide-scopes). |
| **SSO settings** | Only if you ticked the `sso` scope: which agents it may sign in as, and which sites may embed the session. |

You get back a **client id** (`tlc_…`, visible forever) and a **client secret**
(`tls_…`, shown once).

:::warning The secret is shown once
It is stored only as a bcrypt hash. Nobody — not an Owner, not Teloring support —
can retrieve it afterwards. Lost it? Revoke the credential and create another.
:::

## Getting a token

`POST /v1/oauth/token` accepts your credentials three ways. Use whichever your
HTTP client makes easiest; they are equivalent.

<details>
<summary>JSON body</summary>

```bash
curl -X POST https://api.teloring.com/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{"client_id": "tlc_…", "client_secret": "tls_…"}'
```
</details>

<details>
<summary>Form-encoded (what most OAuth libraries send)</summary>

```bash
curl -X POST https://api.teloring.com/v1/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=tlc_… \
  -d client_secret=tls_…
```
</details>

<details>
<summary>HTTP Basic (RFC 6749 §2.3.1)</summary>

```bash
curl -X POST https://api.teloring.com/v1/oauth/token \
  -u "tlc_…:tls_…" \
  -d grant_type=client_credentials
```

If both a Basic header and body parameters are present, the header wins — a body
parameter cannot downgrade it.
</details>

### Cache the token

One token per hour is the expected pattern. The token endpoint is rate limited
to **20 requests per minute per IP**, and ten consecutive failures against one
client id lock that client out for fifteen minutes.

A minimal client, in the shape most people end up writing:

```python
import time, requests

class Teloring:
    BASE = "https://api.teloring.com/v1"

    def __init__(self, client_id, client_secret):
        self._id, self._secret = client_id, client_secret
        self._token, self._expires_at = None, 0

    def _auth_header(self):
        # Refresh a minute early so a request never races the expiry.
        if not self._token or time.time() > self._expires_at - 60:
            response = requests.post(
                f"{self.BASE}/oauth/token",
                json={"client_id": self._id, "client_secret": self._secret},
                timeout=10,
            )
            response.raise_for_status()
            payload = response.json()
            self._token = payload["access_token"]
            self._expires_at = time.time() + payload["expires_in"]
        return {"Authorization": f"Bearer {self._token}"}

    def get(self, path, **params):
        response = requests.get(f"{self.BASE}{path}", headers=self._auth_header(),
                                params=params, timeout=30)
        if response.status_code == 401:      # revoked or edited mid-flight
            self._token = None
            response = requests.get(f"{self.BASE}{path}", headers=self._auth_header(),
                                    params=params, timeout=30)
        response.raise_for_status()
        return response.json()
```

The `401` retry matters more than it looks. Editing a credential's scopes, or
revoking it, invalidates outstanding tokens **immediately** — so a token can stop
working before its stated expiry, and the correct response is to fetch a new one
once, not to crash.

## Using the token

```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
```

Not sure what a token can do? `GET /v1/oauth/introspect` tells you the account,
the credential, and exactly which scopes it holds. It is the fastest way to
explain a surprising `403`.

## When authentication fails

| Status | `code` | What happened | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | A credential field was missing | Send both `client_id` and `client_secret` |
| 400 | `unsupported_grant_type` | `grant_type` was not `client_credentials` | Only that grant is supported |
| 401 | `invalid_client` | Unknown client id, wrong secret, revoked credential, or one past its expiry date | Check the pair; check the credential in Settings → API |
| 401 | `missing_token` | No `Authorization: Bearer` header | Add the header |
| 401 | `invalid_token` | Token expired, tampered with, or its credential was revoked or edited | Fetch a new token |
| 402 | `plan_limit_exceeded` | The account's plan does not include API access | Upgrade the plan |
| 403 | `ip_not_allowed` | The account restricts access by IP and yours is not on the list | Add your server's outbound IP under Settings → Security & login |
| 429 | `too_many_failed_attempts` | Ten consecutive failures on one client id | Wait fifteen minutes; check the secret |
| 429 | `rate_limit_exceeded` | Too many token requests from this IP | Cache the token |

:::note One message for every credential failure
Unknown client id, wrong secret, revoked and expired all answer the same
`401 invalid_client`. Distinguishing them would let somebody enumerate which
client ids exist. Our logs record the real reason — quote the `request_id` from
the response if you need us to look.
:::

## IP restrictions

If the account has an IP allow-list under **Settings → Security & login**, it
applies to the API as well as to sign-in. That list is described in the console
as "who may access the system", and an integration holding a credential *is*
access to the system.

When one is set:

- `POST /v1/oauth/token` refuses a call from an address that is not on the list
  and issues no token at all.
- Every authenticated request is checked again, not just the token exchange. A
  token already in hand stops working the moment it is used from a disallowed
  address — a one-hour window during which a stolen token still worked would be
  a weaker promise than the one Settings makes.

Both answer `403`:

```json
{
  "error": {
    "type": "permission_error",
    "code": "ip_not_allowed",
    "message": "This IP address is not on the account's allow-list. Add it under Settings → Security & login, or call the API from an allowed address.",
    "details": { "client_ip": "203.0.113.55" },
    "request_id": "req_5f2a91c0e8b74d3a9c1e"
  }
}
```

The address we compare is your server's **outbound** IP as it reaches us, which
is often not the address of the machine running your code — a NAT gateway, a
load balancer or an egress proxy usually sits in between. `details.client_ip`
in the error tells you exactly what we saw, so add that.

Both single addresses and CIDR ranges are accepted in the console:

```
198.51.100.7
192.0.2.0/24
```

An account with an empty list is unrestricted, which is the default.

:::caution Serverless and dynamic IPs
If your integration runs somewhere with a changing outbound address (most
serverless platforms, some CI runners), either give it a static egress IP or
leave the allow-list empty. A partially-correct list produces intermittent
`403`s that are miserable to debug.
:::

## Rotating a secret

There is no in-place rotation, on purpose: a credential that can change its own
secret is a credential whose secret can be changed by whoever holds it.

The zero-downtime rotation is two credentials:

1. Create a second credential with the same scopes.
2. Deploy the new client id and secret to your integration.
3. Watch **Last used** on the old credential in Settings → API until it stops
   moving.
4. Revoke the old one.

## Keeping the secret safe

- Store it in a secrets manager or an environment variable — never in source
  control, never in a frontend bundle, never in a URL.
- Give each integration its own credential with its own scopes. One shared
  secret across five systems means a leak anywhere is a leak everywhere, and the
  audit log cannot tell you which system did what.
- Set an expiry on anything temporary.
- Revoke immediately if you suspect exposure. Revocation kills outstanding
  access tokens as well as the secret.
