ahlan hamad

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

EndpointPurpose
GET /oauth/authorizeConsent screen in the user's browser (English and Arabic)
POST /oauth/tokenExchange the code, and refresh tokens (server to server)
POST /oauth/revokeRevoke 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();
ParameterValue
response_typecode
client_idYour app's client ID
redirect_uriOne of your registered URIs, exactly
scopeSpace-separated scopes, within what the app requests
stateA random value you verify on return
code_challengeBASE64URL(SHA256(code_verifier))
code_challenge_methodS256

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"

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.

ScopeAllows
employees:readList and read employees
payroll:readList and read closed payroll runs (totals and journal only)
attendance:readRead attendance back
attendance:writePush clock events
webhooks:manageRegister 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"
}
errorMeaning
invalid_requestA parameter is missing or repeated, or the redirect URI does not exactly match one registered for the app.
invalid_clientUnknown client ID, or wrong client secret.
invalid_grantThe 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_scopeA scope that does not exist, or more than the app requested or the connection was granted.
unauthorized_clientThe 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_typegrant_type other than authorization_code or refresh_token.
access_deniedReturned to your redirect URI when the user declines.