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.
| Address | Who can ask for it | What 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 |
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.
A client of your own
Signed in as yourself,
POST /user/oauth-clients. You can have 10. Choosepublicfor anything that runs on someone else's device, such as a phone app or a page in their browser, andconfidentialfor a server that can keep a secret.A confidential client's
client_secretis in this answer and nowhere else. If you lose it, replace it withPOST /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", … }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", … }A client ID metadata document
Use an
httpsaddress you host asclient_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, orhttponlocalhost,127.0.0.1or[::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.
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)));Send them to sign in
Open the authorize endpoint in their browser.
scopeisapi, andoffline_accessadds 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);Take the code they bring back
They come back to your redirect URI with
code, yourstateandiss. Check thatstateis yours and thatissis the issuer. If they said no, it carrieserror=access_deniedinstead.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.comSwap the code for tokens
Send the code with the verifier, the same
redirect_uriandresource, 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_inseconds, 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.comUse 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_IDErrors
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:resourceisn'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.