ahlan hamad

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)

  1. Sign in to Ahlan Hamad (or create a free account).
  2. 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.
  3. 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

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:

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:

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

  1. The customer enables the Ahlan Hamad Partner API. It’s switched on per company during the pilot, so tell us which customer.
  2. A user on the customer’s side with the integrations.manage permission (CEO or HR admin) creates an ah_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.
  3. 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.