OAuth

OAuth

Let people sign in to your client with their Atli account, and act for them, with standard OAuth 2.1: discovery, PKCE, refresh tokens that rotate, and revocation. Any client can use it: an app, a bot, a website, a server or an AI assistant.

When to use it

  • AI assistants are one such client. Connecting Atli's MCP server, they sign people in this way by themselves, so there's nothing to build for them. The same flow serves every client.

  • Any client that acts for a person: a phone app, a website, a bot or a server that shows someone their eSIMs or buys one for them, with their own Atli account. Register a client of your own, and its tokens work on Atli's API.

  • People who sign in through a client you own, or at your partner address of the MCP server, can be linked to you as your referrals. That is off by default, and Atli can turn it on for your account. Either way, your client can credit the people it brings itself: see crediting the people you bring, below.

  • Only an account someone can sign in to again elsewhere can connect: one with an email, including through Google, Apple or Facebook, or with Telegram or WhatsApp.

How it works

Atli's API is the authorization server. The sign-in and the consent screen are on heyatli.com, at /connect, so your app never sees a password. Read every endpoint from the metadata rather than writing them in. The addresses here follow the Sandbox and Production switch.

Register a client once. Then, for each person: send them to the authorize endpoint with a PKCE challenge, take the code they come back with, swap it for tokens, and call with the access token. Refresh it when it runs out, and revoke it when they sign out.

curl https://api.heyatli.com/.well-known/oauth-authorization-server

{
  "issuer": "https://api.heyatli.com",
  "authorization_endpoint": "https://api.heyatli.com/oauth2/authorize",
  "token_endpoint": "https://api.heyatli.com/oauth2/token",
  "revocation_endpoint": "https://api.heyatli.com/oauth2/revoke",
  "registration_endpoint": "https://api.heyatli.com/oauth2/register",
  "jwks_uri": "https://api.heyatli.com/oauth2/jwks",
  "scopes_supported": ["api", "offline_access"],
  "code_challenge_methods_supported": ["S256"],
  "client_id_metadata_document_supported": true,
  …
}

What a token is for

Every request names the address its token is for, as resource (RFC 8707), and the token works only there. An address that isn't one of these, or a referral code that doesn't exist, is refused with invalid_target, so neither can be guessed.

AddressWho can ask for itWhat the token does
Atli's API
https://api.heyatli.com
Only a client you own

A bearer token for the API, acting as the person, like one from /login.

Atli's MCP server
https://mcp.heyatli.com
Any client

Lets an assistant act for the person through the MCP tools.

Your partner address
https://mcp.heyatli.com/r/YOUR_CODE
Any client

The same, and the person can be linked to you as your referral, once that's turned on for your account.

Register a client

There are three ways. For your own app, register a client of your own: it's the only kind that can ask for Atli's API.

  1. A client of your own

    Signed in as yourself, POST /user/oauth-clients. You can have 10. Choose public for anything that runs on someone else's device, such as a phone app or a page in their browser, and confidential for a server that can keep a secret.

    A confidential client's client_secret is in this answer and nowhere else. If you lose it, replace it with POST /user/oauth-clients/{id}/rotate-secret. Your account needs an email, because a client needs a referral code; one is made for you if you have none yet.

    curl https://api.heyatli.com/user/oauth-clients \
      -H 'Authorization: Bearer YOUR_TOKEN' \
      -H 'Content-Type: application/json' \
      -d '{ "name": "Travel Bot",
            "redirect_uris": ["https://bot.example/oauth/callback"],
            "client_type": "confidential" }'
    
    {
      "client_id": "atli_client_7f3a9c2e…",
      "client_secret": "…",
      "client_type": "confidential",
      …
    }
  2. Dynamic registration

    POST /oauth2/register (RFC 7591), with no sign-in. It's how most assistants register. It allows 10 registrations an hour from one address, and a client nobody ever connects is deleted after 30 days.

    curl https://api.heyatli.com/oauth2/register \
      -H 'Content-Type: application/json' \
      -d '{ "client_name": "Travel Bot",
            "redirect_uris": ["https://bot.example/oauth/callback"] }'
    
    {
      "client_id": "atli_client_…",
      "token_endpoint_auth_method": "none",
      …
    }
  3. A client ID metadata document

    Use an https address you host as client_id, serving a JSON document that describes the client. Atli reads it when someone signs in, so there is nothing to register. The MCP specification prefers this to dynamic registration.

    // Served at https://bot.example/oauth/client.json
    {
      "client_id": "https://bot.example/oauth/client.json",
      "client_name": "Travel Bot",
      "redirect_uris": ["https://bot.example/oauth/callback"],
      "token_endpoint_auth_method": "none"
    }
  • Redirect URIs are matched exactly. Each is https, or http on localhost, 127.0.0.1 or [::1], where only the port may differ, with no fragment. A client can have up to 10.

Sign a person in

The authorization code flow, with PKCE (S256), which every client uses, public or confidential.

  1. Make a PKCE pair

    Keep the verifier for the code exchange, and send its SHA-256 challenge with the request. Make a new pair, and a new state, for every sign-in.

    const base64url = (bytes) =>
      btoa(String.fromCharCode(...bytes))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
    
    const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
    const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
    const challenge = base64url(new Uint8Array(digest));
    const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
  2. Send them to sign in

    Open the authorize endpoint in their browser. scope is api, and offline_access adds a refresh token. They sign in and say yes at heyatli.com/connect, which shows your client's name, where it sends them back, and whether they'd become someone's referral. The request lasts 10 minutes.

    const url = new URL('https://api.heyatli.com/oauth2/authorize');
    url.search = new URLSearchParams({
      response_type: 'code',
      client_id: CLIENT_ID,
      redirect_uri: 'https://bot.example/oauth/callback',
      scope: 'api offline_access',
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
      // Atli's API, for a client you own; or the MCP server, https://mcp.heyatli.com
      resource: 'https://api.heyatli.com',
    }).toString();
    window.location.assign(url);
  3. Take the code they bring back

    They come back to your redirect URI with code, your state and iss. Check that state is yours and that iss is the issuer. If they said no, it carries error=access_denied instead.

    https://bot.example/oauth/callback?code=4bd81f…&state=…&iss=https%3A%2F%2Fapi.heyatli.com
    
    https://bot.example/oauth/callback?error=access_denied&state=…&iss=https%3A%2F%2Fapi.heyatli.com
  4. Swap the code for tokens

    Send the code with the verifier, the same redirect_uri and resource, and your client's credentials if it has any. A code lasts 60 seconds and works once: sending it twice, or with the wrong verifier, ends the person's access, as someone else may have it.

    The access token lasts expires_in seconds, 15 minutes. A refresh token always comes with it.

    curl https://api.heyatli.com/oauth2/token \
      -u 'CLIENT_ID:CLIENT_SECRET' \
      -d grant_type=authorization_code \
      -d code=THE_CODE \
      -d code_verifier=THE_VERIFIER \
      -d redirect_uri=https://bot.example/oauth/callback \
      -d client_id=CLIENT_ID \
      -d resource=https://api.heyatli.com
    
    {
      "access_token": "…",
      "token_type": "Bearer",
      "expires_in": 900,
      "refresh_token": "…",
      "scope": "api offline_access"
    }

Keep them signed in

When the access token runs out, ask for a new one with the refresh token. You can ask for a narrower scope, never a wider one.

The refresh token changes on every use, so keep the new one each time. If an answer never reached you, sending the old token again for a short while gets the same answer back. After that, the old token means it was copied, and the person's access ends.

curl https://api.heyatli.com/oauth2/token \
  -u 'CLIENT_ID:CLIENT_SECRET' \
  -d grant_type=refresh_token \
  -d refresh_token=THE_REFRESH_TOKEN \
  -d client_id=CLIENT_ID \
  -d resource=https://api.heyatli.com

Use the token

Send it as Authorization: Bearer ACCESS_TOKEN, to the address it was issued for and nowhere else.

A token for Atli's API acts as the person on every API route, as a token from /login does. Treat it as opaque: what's inside can change.

A token for an MCP server is a JWT signed with EdDSA. The server checks it against the keys at /oauth2/jwks, by its kid, and checks that its aud is its own address. It holds no email or other details: only sub (the person's id), scope, grant_id and the times.

curl https://api.heyatli.com/esims \
  -H 'Authorization: Bearer ACCESS_TOKEN'

Credit the people you bring

Linking the people who sign in through your client, or at your partner address, to you is off by default, and Atli can turn it on for your account. Whether or not it's on, your client can link a signed-in person to your referral code itself, with their access token: POST /user/referral/redeem with your code.

result says what happened: ok linked them, already means they have a referrer, notnew that they've bought before, own that it's your own code, and notfound that the code doesn't exist. Only a person with no referrer who hasn't bought yet can be linked, and only once.

curl https://api.heyatli.com/user/referral/redeem \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "code": "YOUR_CODE" }'

{
  "success": true,
  "result": "ok",
  "message": "…",
  "discount_percent": 10
}

Sign out and take access away

When someone signs out of your app, revoke their refresh token. That ends their access in your client, with every token on it. It answers 200 even for a token it doesn't know, so it can't be used to find out which tokens exist.

People can also see the apps they've connected and take access away themselves (GET /user/oauth-grants, DELETE /user/oauth-grants/{id}). From then on your client can't get a new token, so treat a refused refresh as signed out.

curl https://api.heyatli.com/oauth2/revoke \
  -u 'CLIENT_ID:CLIENT_SECRET' \
  -d token=THE_REFRESH_TOKEN \
  -d client_id=CLIENT_ID

Errors

The token, revoke and registration endpoints answer with OAuth's own error body, {"error": "invalid_grant", "error_description": "…"}, rather than the API's usual one. Branch on error, never on the description. Every answer from the token endpoint carries Cache-Control: no-store.

  • invalid_grant: the code was used or ran out, the verifier is wrong, or a refresh token was used again after its grace period. Sign the person in again.

  • invalid_target: resource isn't an address a token can be for, or names a referral code that doesn't exist.

  • invalid_client: the client's credentials are wrong or missing. Every kind fails the same way.

  • invalid_scope: a refresh asked for more than the person allowed.