OAuth guide Preview
Preview — not available yet. This page describes the authorization server being built, so you can plan your integration. None of the endpoints below can be called today, and details may change before launch.
The server host ($AH_AUTH_HOST) and token lifetimes are published at launch. Until then, use API keys.
Overview
- OAuth 2.0 authorization code with PKCE (
S256only). - Tokens are opaque (not JWTs) and stored hashed on our side, so revocation is immediate. Don't parse them.
- An access token is bound to three things: your app, the company, and the granted scopes.
/v1acceptsAuthorization: Bearer <token>alongsideah_API keys.
| Endpoint | Purpose |
|---|---|
GET /oauth/authorize | Consent screen in the user's browser (English and Arabic) |
POST /oauth/token | Exchange the code, and refresh tokens (server to server) |
POST /oauth/revoke | Revoke a token (server to server) |
1. Register your app
In the developer console you create an app inside your developer organisation and get a client_id and client_secret. The secret is shown once and can be rotated. Register your redirect URIs exactly — matching is exact — and choose the scopes the app requests. A new app is in development mode and can connect only to your organisation's sandbox companies.
2. Send the user to the authorize URL
Generate a random code_verifier per attempt and keep it on your server, derive the code_challenge from it, and send a random state that you check on the way back.
import crypto from "node:crypto";
// 1. A fresh verifier per authorization attempt; keep it server-side.
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
.createHash("sha256")
.update(codeVerifier)
.digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
// 2. Send the user's browser here.
const url = new URL("/oauth/authorize", process.env.AH_AUTH_HOST);
url.search = new URLSearchParams({
response_type: "code",
client_id: process.env.AH_CLIENT_ID,
redirect_uri: "https://yourapp.example/callback/ahlan-hamad",
scope: "employees:read payroll:read",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
}).toString();| Parameter | Value |
|---|---|
response_type | code |
client_id | Your app's client ID |
redirect_uri | One of your registered URIs, exactly |
scope | Space-separated scopes, within what the app requests |
state | A random value you verify on return |
code_challenge | BASE64URL(SHA256(code_verifier)) |
code_challenge_method | S256 |
The user signs in to Ahlan Hamad. A user with integrations.manage picks the company, sees your app's name and the scopes requested, and approves or declines. We send them back to your URI:
https://yourapp.example/callback/ahlan-hamad?code=…&state=…
https://yourapp.example/callback/ahlan-hamad?error=access_denied&state=…3. Exchange the code for tokens
The authorization code is single-use and valid for 60 seconds. Exchange it from your server with the same redirect_uri and the code_verifier.
curl -X POST "$AH_AUTH_HOST/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=authorization_code \
-d code="$CODE" \
-d redirect_uri="https://yourapp.example/callback/ahlan-hamad" \
-d code_verifier="$CODE_VERIFIER" \
-d client_id="$AH_CLIENT_ID" \
-d client_secret="$AH_CLIENT_SECRET"{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "employees:read payroll:read"
}The expires_in value above is illustrative only; lifetimes are set at launch.
4. Call the API
curl https://actions.ahlanhamad.com/v1/company \
-H "Authorization: Bearer $ACCESS_TOKEN"Same endpoints, same scope checks, same rate limit and the same problem+json errors as with API keys. A revoked or expired token returns 401 unauthenticated.
5. Refresh
curl -X POST "$AH_AUTH_HOST/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token="$REFRESH_TOKEN" \
-d client_id="$AH_CLIENT_ID" \
-d client_secret="$AH_CLIENT_SECRET"- Refresh tokens rotate: every refresh returns a new refresh token and invalidates the old one. Store the new one before you use the access token.
- Reuse detection: presenting a refresh token that was already used revokes the whole token family for that connection, and the customer has to approve again. Make sure only one worker refreshes at a time.
- A refresh can ask for fewer scopes than were granted, never more.
6. Revoke
curl -X POST "$AH_AUTH_HOST/oauth/revoke" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token="$REFRESH_TOKEN" \
-d client_id="$AH_CLIENT_ID" \
-d client_secret="$AH_CLIENT_SECRET"Revoke tokens when the customer disconnects your product. The customer can also revoke your app from its Connected apps page; your tokens stop at once and that connection's webhooks are disabled.
Scopes
The same scopes as API keys. GET /v1/company needs none. Request the least your integration needs — the customer sees the list before approving.
| Scope | Allows |
|---|---|
employees:read | List and read employees |
payroll:read | List and read closed payroll runs (totals and journal only) |
attendance:read | Read attendance back |
attendance:write | Push clock events |
webhooks:manage | Register and manage webhook endpoints |
No auth path — key or OAuth — returns individual employees' pay.
Errors
/oauth/token and /oauth/revoke errors use the standard OAuth shape (RFC 6749 §5.2): error and error_description. Authorize errors come back to your redirect_uri as query parameters — unless the redirect URI or client_id itself is invalid, in which case the user sees the error and is not redirected.
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or was already used"
}error | Meaning |
|---|---|
invalid_request | A parameter is missing or repeated, or the redirect URI does not exactly match one registered for the app. |
invalid_client | Unknown client ID, or wrong client secret. |
invalid_grant | The code expired (60 s), was already used, or was issued to another client or redirect URI; the PKCE verifier does not match; or the refresh token was already used or revoked. |
invalid_scope | A scope that does not exist, or more than the app requested or the connection was granted. |
unauthorized_client | The app may not connect to this company — for example a development-mode app and a company that is not one of your sandboxes. |
unsupported_grant_type | grant_type other than authorization_code or refresh_token. |
access_denied | Returned to your redirect URI when the user declines. |