Ahlan Hamad Partner API: Quickstart
Draft 1, 29 Sep 2026. We’re sharing this contract before launch so you can build alongside us. The full reference is at ahlanhamad.com/developers/reference, and the contract itself is
openapi.yaml. Each endpoint is tagged with the release it ships in:
Release What you can do v1.0 Read company, employees, and closed payroll runs v1.1 Push clock events (clock in/out, breaks) and read attendance back v1.2 Webhooks: register endpoints, receive signed events, inspect and redeliver Details may still change before each release ships. After a release ships, v1 only grows: we add new fields, new optional parameters, new enum values and new event types, and never remove any.
1. Get a sandbox and a key (no need to contact us)
- Sign in to Ahlan Hamad (or create a free account).
- Go to Integrations → API keys (or
ahlanhamad.com/developers) and press Create sandbox company. You get a private company pre-filled with fake employees and one finalized payroll run. - In that sandbox company, press Create API key. Give it a name and choose the scopes you need. Copy the key when it appears; it is shown once.
Sandbox keys start with ah_test_. Keys for real companies start with ah_live_, and a customer creates them for you in their own account. Each kind only works with its own kind of company.
| Scope | Allows |
|---|---|
| (none) | GET /v1/company |
employees:read |
List and read employees |
payroll:read |
List and read closed payroll runs (totals and journal only) |
attendance:write |
Push clock events |
attendance:read |
Read attendance back |
webhooks:manage |
Register and manage webhook endpoints |
A key’s scopes are fixed when it is created. To change them, create a new key and revoke the old one.
2. Your first request
curl https://actions.ahlanhamad.com/v1/company \
-H "Authorization: Bearer ah_test_XXXXXXXXXXXXXXXX"
{
"id": "k57f…",
"name": { "en": "Sandbox Trading Co.", "ar": "شركة التجربة للتجارة" },
"country": "AE",
"currency": "AED",
"timezone": "Asia/Dubai",
"sandbox": true,
"entities": [
{
"id": "j9x…",
"country": "AE",
"name": { "en": "Sandbox Trading Co. LLC", "ar": null },
"currency": "AED",
"primary": true
}
]
}
3. Conventions
- JSON over HTTPS. Field names are
snake_case. Ignore fields you don’t recognise. - Times: RFC 3339 with an offset. When you send times, the offset is required (
2026-09-29T08:02:11+04:00). Times we return are in UTC. - Money is a string:
"1250.500", always next to acurrency. KWD, BHD and OMR use 3 decimals; SAR, AED and QAR use 2. Never parse money as a float. - IDs are opaque. Don’t parse them.
- Every response includes an
X-Request-Idheader. Include it when you contact support.
Pagination
List endpoints return { "data": [...], "next_cursor": "..." }. Pass next_cursor back as ?cursor= until it is null. The page size is set with ?limit= (default 50, maximum 200).
curl "https://actions.ahlanhamad.com/v1/employees?status=active&limit=200" \
-H "Authorization: Bearer $AH_KEY"
Rate limits
The default is 600 requests per minute per key. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). When you exceed the limit you get 429 with Retry-After. Wait that long, then retry.
Errors
Errors use application/problem+json:
{
"type": "https://ahlanhamad.com/developers/errors#insufficient_scope",
"title": "This key does not have the payroll:read scope",
"status": 403,
"code": "insufficient_scope",
"request_id": "req_01J9…"
}
Branch on code, not on title.
| Status | code |
Meaning |
|---|---|---|
| 400 | invalid_request |
Bad parameter, invalid JSON, or unknown cursor |
| 401 | unauthenticated |
Missing, malformed or revoked key, or a test key used on a live company (or the reverse) |
| 403 | insufficient_scope |
The key is valid but lacks the scope |
| 403 | api_not_enabled |
The Partner API isn’t enabled for this company yet |
| 404 | not_found |
The resource isn’t in this company, or isn’t visible to partners (for example a draft payroll run) |
| 409 | conflict |
The request conflicts with the current state (for example redelivering to a disabled endpoint) |
| 413 | payload_too_large |
More than 500 events, or a body over 1 MB |
| 422 | validation_failed |
The body is well-formed but invalid. See errors[] for each field |
| 429 | rate_limited |
Slow down. See Retry-After |
| 5xx | internal_error |
Our fault. Retry with backoff |
4. What data you get, and what you don’t
Employees: name (EN/AR), staff number, job title, department, status, employment type, country, legal entity, branch, manager, contact email and phone, hire and termination dates.
Payroll runs: only runs that are finalized or paid. You get totals, a breakdown by pay component (each with the number of employees it applies to), and the double-entry journal we posted, with account code, account name, debit and credit.
Never returned in v1: individual salaries or allowances, bank details and IBANs, civil ID or passport numbers, dates of birth, social insurance numbers, documents, or reasons for leaving. An employee’s status is simply left.
5. Pushing clock events (v1.1, for POS and time-clock partners)
curl -X POST https://actions.ahlanhamad.com/v1/attendance/events \
-H "Authorization: Bearer $AH_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{ "external_id": "sess-8812-in", "employee_ref": {"type":"external_id","value":"cashier-0042"},
"type": "clock_in", "at": "2026-09-29T08:02:11+04:00", "branch_ref": "dubai-marina-01", "source": "qompos" },
{ "external_id": "sess-8812-out", "employee_ref": {"type":"external_id","value":"cashier-0042"},
"type": "clock_out", "at": "2026-09-29T16:31:40+04:00", "branch_ref": "dubai-marina-01", "source": "qompos" }
]
}'
The response is 207, with one result per event in the order you sent them:
status |
What it means | What to do |
|---|---|---|
accepted |
Stored and matched to an employee | Nothing |
duplicate |
We already have this external_id |
Nothing. Retrying is always safe |
unmatched_employee |
Stored, but we don’t know who this is yet | Nothing. The customer’s HR team links the person once, and every stored event for them is then processed automatically. Don’t resend |
rejected |
Not stored. The reason is in reason |
Fix and resend |
Rules and tips:
- Send your own identifier for the cashier as
employee_ref.type = "external_id". The customer links it to the employee once in Ahlan Hamad.employee_code,emailandphonealso work as ways to match. - Up to 500 events per request. Batch every few minutes, or send in real time; both work.
atcan’t be more than 5 minutes in the future or more than 35 days in the past.- We pair events into shifts in the company’s timezone. A shift that crosses midnight belongs to the day it started, and break time is subtracted.
- An unknown
branch_refnever causes an event to be rejected.
6. Webhooks (v1.2)
Register an endpoint with POST /v1/webhooks, with body { "url": "https://…", "events": ["employee.updated", "payroll_run.finalized"] }. The response includes the signing secret (whsec_…) once.
| Event | When |
|---|---|
employee.created / employee.updated / employee.left |
Something a partner can see about an employee changed |
payroll_run.finalized |
A run was closed and its journal posted |
payroll_run.paid |
A run was marked paid and its payment journal posted |
payroll_run.corrected |
A closed run’s amounts changed. Fetch it again |
payroll_run.deleted |
A finalized run was deleted and its journal reversed |
attendance.rejected |
A stored clock event couldn’t become attendance |
ping |
Test event |
Delivery rules:
- At least once, not necessarily in order. Deduplicate on the event
id. - Reply
2xxwithin 10 seconds. Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. After 7 days of failures the endpoint is disabled and the company is emailed. - Debug on your own:
GET /v1/webhooks/{id}/deliveriesshows the last 100 attempts: what we sent, your response code and the first 1 KB of your response.POST /v1/webhooks/{id}/deliveries/{delivery_id}/redeliversends one again.
Verifying signatures
Every delivery includes X-AH-Signature: t=<unix seconds>,v1=<hex>, where v1 = HMAC-SHA256(secret, "<t>.<raw body>"). Verify against the raw body bytes, before you parse the JSON. Reject the delivery if the signature doesn’t match or t is more than 5 minutes from your clock.
Node.js
import crypto from "node:crypto";
export function verifyAhSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && crypto.timingSafeEqual(given, want);
}
Python
import hashlib, hmac, time
def verify_ah_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
PHP
function verify_ah_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
parse_str(str_replace(',', '&', $header), $parts);
$t = (int)($parts['t'] ?? 0);
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
7. Going live with a customer
- The customer enables the Ahlan Hamad Partner API. It’s switched on per company during the pilot, so tell us which customer.
- A user on the customer’s side with the
integrations.managepermission (CEO or HR admin) creates anah_live_key with the scopes you need and gives it to you. They can see when it was last used and revoke it at any time. - Your code doesn’t change. Only the key does.
Using the API means you accept the API Terms and the data-processing terms linked from them.
Support
Email support@ahlanhamad.com and include the X-Request-Id of the call you’re asking about.