Get started

Authentication

Arkaayu uses standard OAuth 2.0: the authorization code grant with PKCE (S256 only), plus refresh tokens and token revocation. If you've used "Sign in with Google" or Strava's API, this will feel familiar — and any spec-compliant OAuth library will work.

How it fits together#

  1. Your app sends the user's browser to /oauth/authorize with your client ID, redirect URI, the scopes you want, a state value and a PKCE code_challenge.
  2. The user signs in on an Arkaayu page (with an emailed one-time code if they've turned on two-step sign-in), reads the consent screen and approves.
  3. Arkaayu redirects the browser to your redirect URI with a short-lived code and your state.
  4. Your server exchanges the code, the code_verifier and your client credentials for an access token and a refresh token at /oauth/token.
  5. You call the API with Authorization: Bearer <access_token>, and refresh the token when it expires.
EndpointURL
DiscoveryGET https://api.arkaayu.health/oauth/.well-known/oauth-authorization-server
AuthorizeGET https://api.arkaayu.health/oauth/authorize
TokenPOST https://api.arkaayu.health/oauth/token
RevokePOST https://api.arkaayu.health/oauth/revoke

Discovery#

The authorization server publishes its metadata (RFC 8414), so libraries can configure themselves:

Shell
curl https://api.arkaayu.health/oauth/.well-known/oauth-authorization-server
Response
{
  "issuer": "https://api.arkaayu.health",
  "authorization_endpoint": "https://api.arkaayu.health/oauth/authorize",
  "token_endpoint": "https://api.arkaayu.health/oauth/token",
  "revocation_endpoint": "https://api.arkaayu.health/oauth/revoke",
  "scopes_supported": [
    "read:profile", "read:sleep", "read:steps", "read:heart_rate",
    "read:spo2", "read:workouts", "read:ecg"
  ],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256"]
}

Step 1: Create a PKCE pair and a state#

For every sign-in attempt, generate:

  • code_verifier — a high-entropy random string, 43–128 characters from A–Z a–z 0–9 - . _ ~.
  • code_challenge — BASE64URL(SHA256(code_verifier)), without = padding. Only S256 is accepted; plain is rejected.
  • state — a random value tied to the user's session. It's required, and you must check it when the user comes back. It's your protection against cross-site request forgery.

Node.js

import crypto from "node:crypto";

const codeVerifier = crypto.randomBytes(32).toString("base64url");   // 43 chars
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
// Save codeVerifier and state in the user's server-side session.

Python

import base64, hashlib, secrets

code_verifier = secrets.token_urlsafe(48)                    # 64 chars
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
state = secrets.token_urlsafe(16)
# Save code_verifier and state in the user's server-side session.

Browser

// For a single-page app that talks to your own backend: create the pair in
// the browser, keep the verifier in sessionStorage, and let the backend do
// the token exchange (it holds the client secret).
const bytes = crypto.getRandomValues(new Uint8Array(32));
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const codeVerifier = b64url(bytes);
const codeChallenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)));

Step 2: Send the user to the authorize endpoint#

Redirect the user's browser (don't use an embedded web view) to:

HTTP
GET https://api.arkaayu.health/oauth/authorize
    ?response_type=code
    &client_id=hx_7Kq2mWc9TzR4
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Farkaayu%2Fcallback
    &scope=read%3Aprofile%20read%3Asleep
    &state=af0ifjsldkj3x9
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    &code_challenge_method=S256
ParameterRequiredDescription
response_typeYesAlways code.
client_idYesYour app's client ID from the console (hx_…).
redirect_uriYesMust match one of your app's registered redirect URIs exactly — same scheme, host, port, path, trailing slash and query string. We do a plain string comparison.
scopeYesSpace-separated list of scopes. Each must be enabled for your app.
stateYesOpaque value returned to you unchanged. Check it on return.
code_challengeYesThe S256 challenge from step 1.
code_challenge_methodYesAlways S256.

What the user sees:

  1. An Arkaayu sign-in page. If they've enabled two-step sign-in, we email them a one-time code to enter.
  2. A consent screen with your app's name, your website and the description of each scope you asked for.
  3. They choose Allow or Cancel.

If your app is in test mode and the person isn't the owner or one of its test users, they see "This app is still being tested" and can't continue.

Invalid client or redirect URIIf the client_id is unknown or the redirect_uri isn't registered, Arkaayu shows an error page and does not redirect back to you — we never send users to an unverified URL.

Step 3: Handle the redirect#

When the user approves, their browser is sent to your redirect URI:

HTTP
GET https://app.example.com/oauth/arkaayu/callback?code=Splx10BeZQQYbYS6WxSbIA&state=af0ifjsldkj3x9

If they cancel, you get an error instead of a code:

HTTP
GET https://app.example.com/oauth/arkaayu/callback?error=access_denied&state=af0ifjsldkj3x9

Always compare state with the value in the session before doing anything else, and reject the request if it doesn't match. The code is valid for 5 minutes and can be used once.

Step 4: Exchange the code for tokens#

From your server, POST a form-encoded body (application/x-www-form-urlencoded) to the token endpoint:

FieldValue
grant_typeauthorization_code
codeThe code from the redirect.
redirect_uriThe same redirect URI you sent to /oauth/authorize.
code_verifierThe verifier you created in step 1.

Authenticate your app with HTTP Basic (client_id as the username, client_secret as the password — preferred) or by putting client_id and client_secret in the form body.

curl

curl https://api.arkaayu.health/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=Splx10BeZQQYbYS6WxSbIA \
  -d redirect_uri=https://app.example.com/oauth/arkaayu/callback \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Node.js

const res = await fetch("https://api.arkaayu.health/oauth/token", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
  },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: REDIRECT_URI,
    code_verifier: session.codeVerifier,
  }),
});
const tokens = await res.json();
if (!res.ok) throw new Error(`${tokens.error}: ${tokens.error_description}`);

Python

import requests

res = requests.post(
    "https://api.arkaayu.health/oauth/token",
    auth=(CLIENT_ID, CLIENT_SECRET),          # HTTP Basic
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "code_verifier": session["code_verifier"],
    },
    timeout=10,
)
tokens = res.json()
if not res.ok:
    raise RuntimeError(f'{tokens["error"]}: {tokens.get("error_description")}')
Response · 200 OK
{
  "access_token": "q8B7vN2kXwP4tRz9LmC1yHs6DfJe3UaG",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "Zr5Tq1NcV8xWb2KpM7hYd4LsF9eA6uGj0RkE",
  "scope": "read:profile read:sleep"
}

The scope in the response is what the user actually granted. Store both tokens encrypted, against the user in your database. Treat token values as opaque strings — their format and length may change.

Step 5: Call the API#

Shell
curl https://api.arkaayu.health/api/me -H "Authorization: Bearer $ACCESS_TOKEN"

See the API reference for every endpoint.

Refreshing tokens#

Access tokens expire after 1 hour (expires_in: 3600). Refresh tokens last 30 days and rotate on every use: each refresh returns a new refresh token, and the old one stops working.

curl

curl https://api.arkaayu.health/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token=Zr5Tq1NcV8xWb2KpM7hYd4LsF9eA6uGj0RkE

Node.js

async function refresh(user) {
  const res = await fetch("https://api.arkaayu.health/oauth/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
    },
    body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: user.refreshToken }),
  });
  const tokens = await res.json();
  if (!res.ok) {
    if (tokens.error === "invalid_grant") return markDisconnected(user); // ask the user to reconnect
    throw new Error(tokens.error);
  }
  // Save BOTH tokens — the old refresh token is now dead.
  await saveTokens(user.id, tokens);
  return tokens.access_token;
}

Python

def refresh(user):
    res = requests.post(
        "https://api.arkaayu.health/oauth/token",
        auth=(CLIENT_ID, CLIENT_SECRET),
        data={"grant_type": "refresh_token", "refresh_token": user.refresh_token},
        timeout=10,
    )
    tokens = res.json()
    if not res.ok:
        if tokens.get("error") == "invalid_grant":
            return mark_disconnected(user)   # ask the user to reconnect
        raise RuntimeError(tokens["error"])
    # Save BOTH tokens — the old refresh token is now dead.
    save_tokens(user.id, tokens)
    return tokens["access_token"]

Store the new refresh token every timeIf you send a refresh token that has already been used, we treat it as a possible leak and may revoke the whole token family — the user would then have to connect your app again. If several workers can refresh the same user, use a lock so only one refreshes at a time.

You can pass an optional scope parameter to get an access token with fewer scopes than the user granted (for example, give a background job only read:steps). You can't use it to add scopes.

When a refresh fails with invalid_grant, the user has disconnected your app, the refresh token expired (30 days without use), or the app was suspended. Show a "Reconnect Arkaayu" prompt.

Revoking tokens#

When a user disconnects Arkaayu inside your app, or deletes their account with you, revoke their tokens (RFC 7009). You can send either the access token or the refresh token:

curl

curl https://api.arkaayu.health/oauth/revoke \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d token=Zr5Tq1NcV8xWb2KpM7hYd4LsF9eA6uGj0RkE

Node.js

await fetch("https://api.arkaayu.health/oauth/revoke", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
    Authorization: "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64"),
  },
  body: new URLSearchParams({ token: user.refreshToken }),
});

Python

requests.post(
    "https://api.arkaayu.health/oauth/revoke",
    auth=(CLIENT_ID, CLIENT_SECRET),
    data={"token": user.refresh_token},
    timeout=10,
)

The revoke endpoint always returns 200 OK — even if the token was already invalid or unknown — so you can safely retry. Delete the tokens on your side as well.

Errors#

The token and revoke endpoints return errors as JSON, following RFC 6749 §5.2:

Response · 400 Bad Request
{
  "error": "invalid_grant",
  "error_description": "Authorization code has expired or was already used"
}
ErrorStatusMeaning
invalid_request400A required parameter is missing or malformed (for example, no code_verifier).
invalid_client401Client authentication failed: unknown client_id, wrong secret, or the app was deleted or suspended.
invalid_grant400The code or refresh token is invalid, expired, already used or revoked, the redirect_uri doesn't match, or the code_verifier doesn't match the challenge.
unsupported_grant_type400grant_type isn't authorization_code or refresh_token.
invalid_scope400A requested scope is unknown, not enabled for your app, or (on refresh) broader than what was granted.

Errors on the authorize step come back on your redirect URI as ?error=…&state=… (for example access_denied when the user cancels), except for an invalid client or redirect URI, which are shown to the user instead.

API errors with a token#

When calling /api/*:

  • 401 Unauthorized — the access token is missing, invalid, expired or revoked. The response includes WWW-Authenticate: Bearer error="invalid_token". Refresh the token and retry once; if that fails, ask the user to reconnect.
  • 403 Forbidden with insufficient_scope — the token doesn't include the scope the endpoint needs. The WWW-Authenticate header names it, e.g. Bearer error="insufficient_scope", scope="read:sleep", and the body is {"error": "insufficient_scope", "error_description": "…"}.

More in Errors & rate limits.

Security rules#

  • Keep the client secret on your server. Never put it in a mobile app, desktop app, browser JavaScript or a public repository. If it leaks, rotate it in the console immediately — the old secret stops working at once, while existing access and refresh tokens stay valid.
  • Always use PKCE and state. Generate fresh values for each sign-in and check state on return.
  • Use HTTPS redirect URIs. Plain http is allowed only for localhost in test-mode apps, and localhost URIs must be removed before you submit for review.
  • Store tokens encrypted at rest, never log them, and never send them to the browser if you can avoid it.
  • Use the system browser on mobile (ASWebAuthenticationSession on iOS, Custom Tabs on Android), not an embedded web view, so users can see they're on an Arkaayu page.
  • Revoke tokens when a user disconnects or deletes their account with you.

Mobile and single-page apps#

Apps that can't keep a secret should not hold one. The recommended pattern is a small backend that owns the client secret and does the token exchange and refresh; your mobile or browser app talks only to that backend. Discovery lists the none authentication method for public clients, but whether a given app can use it depends on how the app is registered — email developers@arkaayu.com if you need a public client.

Questions or something unclear? Write to developers@arkaayu.com.