openapi: 3.1.0
info:
  title: Ahlan Hamad Partner API
  version: 1.0.0-draft.1
  summary: Read company, employee and payroll data; push attendance; receive signed webhooks.
  description: |
    The Ahlan Hamad Partner API lets approved partners (accounting, point-of-sale,
    banking) integrate with a company's HR and payroll data in Ahlan Hamad.

    **Status: DRAFT.** This contract is published ahead of the implementation so
    partners can build in parallel. Every operation carries an `x-release` marker:

    | Release | Contents |
    |---|---|
    | `v1.0` | Company, employees, payroll runs (read) |
    | `v1.1` | Attendance ingest + read-back |
    | `v1.2` | Webhook endpoints + events |

    Shapes may still change before each release ships; after it ships, v1 only
    changes additively (new fields, new optional parameters, new enum values,
    new event types). Clients must ignore unknown fields and tolerate unknown
    enum values.

    **Data protection.** v1 never returns salaries, allowances per employee,
    bank details (IBAN), civil ID / passport numbers, dates of birth, social
    insurance numbers, or documents. Payroll data is available only as run-level
    totals and journal lines.

    **Conventions**
    - JSON over HTTPS, UTF-8. Field names are `snake_case`.
    - Timestamps are RFC 3339 with an explicit offset (`2026-09-29T08:02:11+04:00`).
      Timestamps we return are UTC (`Z`).
    - Calendar dates are `YYYY-MM-DD`; payroll months are `YYYY-MM`.
    - Money is a **decimal string** in the currency's minor-unit precision
      (`"1250.500"` KWD/BHD/OMR, `"1250.50"` SAR/AED/QAR), always paired with an
      ISO 4217 `currency`. Never parse money as a float.
    - IDs are opaque strings. Do not infer structure from them.
  contact:
    name: Ahlan Hamad Partner Support
    email: support@ahlanhamad.com
    url: https://ahlanhamad.com/developers
  termsOfService: https://ahlanhamad.com/developers/terms
servers:
  - url: https://actions.ahlanhamad.com
    description: Production (live and sandbox companies)
  - url: https://actions.ahlanhamad.dev
    description: Ahlan Hamad internal development — not for partner use

security:
  - apiKey: []

tags:
  - name: Company
  - name: Employees
  - name: Payroll
  - name: Attendance
  - name: Webhooks

paths:
  /v1/company:
    get:
      tags: [Company]
      operationId: getCompany
      summary: The company this API key belongs to
      description: Available to every valid key; no scope required.
      x-release: v1.0
      responses:
        "200":
          description: The company.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit:
              { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining:
              { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset:
              { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Company" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/employees:
    get:
      tags: [Employees]
      operationId: listEmployees
      summary: List employees
      description: |
        Returns partner-safe employee records, oldest first. Archived records are
        never returned. To follow changes, subscribe to the `employee.*` webhooks
        (v1.2) instead of polling; there is no `updated_since` filter in v1.
      x-release: v1.0
      x-required-scope: employees:read
      security:
        - apiKey: [employees:read]
      parameters:
        - name: status
          in: query
          description: |
            `active` — currently employed (every status except `left`).
            `left` — employment has ended. Omit for both.
          schema:
            type: string
            enum: [active, left]
        - name: entity_id
          in: query
          description: Only employees of this legal entity (see `Company.entities`).
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of employees.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit:
              { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining:
              { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset:
              { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Employee" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/employees/{employee_id}:
    get:
      tags: [Employees]
      operationId: getEmployee
      summary: Get one employee
      x-release: v1.0
      x-required-scope: employees:read
      security:
        - apiKey: [employees:read]
      parameters:
        - $ref: "#/components/parameters/EmployeeId"
      responses:
        "200":
          description: The employee.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Employee" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payroll-runs:
    get:
      tags: [Payroll]
      operationId: listPayrollRuns
      summary: List closed payroll runs
      description: |
        Returns only runs that are `finalized` or `paid`, newest month first.
        Draft runs are never exposed. A company that pays in several countries
        has one run per country (and legal entity) per month.
      x-release: v1.0
      x-required-scope: payroll:read
      security:
        - apiKey: [payroll:read]
      parameters:
        - name: from
          in: query
          description: First payroll month to include (inclusive).
          schema: { $ref: "#/components/schemas/Month" }
        - name: to
          in: query
          description: Last payroll month to include (inclusive).
          schema: { $ref: "#/components/schemas/Month" }
        - name: status
          in: query
          schema:
            type: string
            enum: [finalized, paid]
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of payroll runs (summary form).
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
            X-RateLimit-Limit:
              { $ref: "#/components/headers/X-RateLimit-Limit" }
            X-RateLimit-Remaining:
              { $ref: "#/components/headers/X-RateLimit-Remaining" }
            X-RateLimit-Reset:
              { $ref: "#/components/headers/X-RateLimit-Reset" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items:
                          { $ref: "#/components/schemas/PayrollRunSummary" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/payroll-runs/{payroll_run_id}:
    get:
      tags: [Payroll]
      operationId: getPayrollRun
      summary: Get a closed payroll run with its components and journal
      description: |
        Run totals, a breakdown by pay component (each with the number of
        employees it applies to), and the double-entry journal Ahlan Hamad
        posted for the run. No per-employee amounts are returned.
      x-release: v1.0
      x-required-scope: payroll:read
      security:
        - apiKey: [payroll:read]
      parameters:
        - $ref: "#/components/parameters/PayrollRunId"
      responses:
        "200":
          description: The payroll run.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PayrollRun" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/attendance/events:
    post:
      tags: [Attendance]
      operationId: ingestAttendanceEvents
      summary: Push clock events (clock in/out, breaks)
      description: |
        Submit up to 500 events per request. Each event is processed
        independently and the response lists one result per event, **in the
        same order** as the request.

        **Idempotent on `external_id`** (per company and `source`): resending an
        event you already sent returns `duplicate` and changes nothing, so it is
        always safe to retry a request that timed out.

        **Employee matching.** `employee_ref` identifies the person. Events whose
        employee cannot be matched are **stored, not dropped**, with result
        `unmatched_employee`: the company's HR team links the person once in
        Ahlan Hamad and every stored event for them is then processed
        automatically. You do not need to resend.

        **Branches.** `branch_ref` is your identifier for the location. An
        unknown branch never rejects an event — it is stored as given and HR can
        map it later.

        Clock events are paired into shifts in the company's timezone; a shift
        that crosses midnight belongs to the day it started. Break time is
        subtracted from worked time.
      x-release: v1.1
      x-required-scope: attendance:write
      security:
        - apiKey: [attendance:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/AttendanceEventInput" }
            example:
              events:
                - external_id: qompos-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: qompos-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
      responses:
        "207":
          description: Per-event results, in request order.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      { $ref: "#/components/schemas/AttendanceEventResult" }
              example:
                results:
                  - {
                      external_id: qompos-sess-8812-in,
                      status: accepted,
                      event_id: "ae_7k2…",
                    }
                  - {
                      external_id: qompos-sess-8812-out,
                      status: duplicate,
                      event_id: "ae_7k3…",
                    }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: More than 500 events, or body larger than 1 MB.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/attendance:
    get:
      tags: [Attendance]
      operationId: listAttendanceDays
      summary: Read back daily attendance
      description: |
        One record per employee per day, as Ahlan Hamad holds it after pairing
        clock events. Includes days recorded by other sources (the employee app,
        manual HR entries) — check `sources`.
      x-release: v1.1
      x-required-scope: attendance:read
      security:
        - apiKey: [attendance:read]
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: to
          in: query
          required: true
          description: Inclusive. At most 62 days after `from`.
          schema: { type: string, format: date }
        - name: employee_id
          in: query
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of attendance days.
          headers:
            X-Request-Id: { $ref: "#/components/headers/X-Request-Id" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/AttendanceDay" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhookEndpoints
      summary: List webhook endpoints registered by this key
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      responses:
        "200":
          description: Endpoints owned by the calling key.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Webhooks]
      operationId: createWebhookEndpoint
      summary: Register a webhook endpoint
      description: |
        The response includes the endpoint's signing `secret` **once**. Store it;
        it cannot be retrieved again (rotate by deleting and re-creating).
        The URL must be `https://` on a public host.
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: Signing secret (`whsec_…`). Shown only in this response.
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /v1/webhooks/{webhook_id}:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Webhooks]
      operationId: getWebhookEndpoint
      summary: Get a webhook endpoint
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhookEndpoint
      summary: Change URL, events, or re-enable an endpoint
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookEventType" }
                active: { type: boolean }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      responses:
        "204": { description: Deleted. Pending deliveries are cancelled. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{webhook_id}/test:
    post:
      tags: [Webhooks]
      operationId: sendTestWebhook
      summary: Send a `ping` event to the endpoint now
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      parameters:
        - name: webhook_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "202": { description: Queued for immediate delivery. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{webhook_id}/deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: Recent delivery attempts for an endpoint (self-debugging)
      description: |
        The last 100 deliveries to this endpoint, newest first: what we sent,
        when, what your server answered, and whether we will retry. The same
        log is visible to the company in Ahlan Hamad under Integrations → API
        keys. Deliveries older than 30 days are removed.
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      parameters:
        - name: webhook_id
          in: path
          required: true
          schema: { type: string }
        - name: status
          in: query
          schema:
            type: string
            enum: [succeeded, failed, pending]
      responses:
        "200":
          description: Up to 100 deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    maxItems: 100
                    items: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver:
    post:
      tags: [Webhooks]
      operationId: redeliverWebhook
      summary: Send one past event again now
      description: |
        Re-sends the original event (same event `id`, same payload, fresh
        signature and timestamp) as a new delivery attempt. Works for any
        delivery in the log, including successful ones, and on an endpoint that
        was auto-disabled — re-enable it first with `PATCH`.
      x-release: v1.2
      x-required-scope: webhooks:manage
      security:
        - apiKey: [webhooks:manage]
      parameters:
        - name: webhook_id
          in: path
          required: true
          schema: { type: string }
        - name: delivery_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "202":
          description: Queued. The new attempt appears in the deliveries log.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The endpoint is disabled.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

webhooks:
  employee.created:
    post:
      tags: [Webhooks]
      summary: An employee was added
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: employee.created }
                    data:
                      type: object
                      properties:
                        employee: { $ref: "#/components/schemas/Employee" }
      responses:
        "2XX":
          {
            description: Acknowledged. Any other status (or no answer within 10 s) is retried.,
          }
  employee.updated:
    post:
      tags: [Webhooks]
      summary: A partner-visible field of an employee changed
      description: |
        Sent when any field in the `Employee` schema changes. Delivered at least
        once and possibly late (a nightly reconciliation catches changes made
        through bulk tools); always re-read the embedded object rather than
        diffing.
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: employee.updated }
                    data:
                      type: object
                      properties:
                        employee: { $ref: "#/components/schemas/Employee" }
      responses:
        "2XX": { description: Acknowledged. }
  employee.left:
    post:
      tags: [Webhooks]
      summary: An employee's employment ended
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: employee.left }
                    data:
                      type: object
                      properties:
                        employee: { $ref: "#/components/schemas/Employee" }
      responses:
        "2XX": { description: Acknowledged. }
  payroll_run.finalized:
    post:
      tags: [Webhooks]
      summary: A payroll run was closed and its journal posted
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: payroll_run.finalized }
                    data:
                      type: object
                      properties:
                        payroll_run:
                          { $ref: "#/components/schemas/PayrollRunSummary" }
      responses:
        "2XX": { description: Acknowledged. }
  payroll_run.paid:
    post:
      tags: [Webhooks]
      summary: A payroll run was marked paid and its payment journal posted
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: payroll_run.paid }
                    data:
                      type: object
                      properties:
                        payroll_run:
                          { $ref: "#/components/schemas/PayrollRunSummary" }
      responses:
        "2XX": { description: Acknowledged. }
  payroll_run.corrected:
    post:
      tags: [Webhooks]
      summary: A closed payroll run's amounts changed
      description: |
        A correction was applied to a run that was already `finalized` or
        `paid`. Totals and journal lines may have changed — re-fetch the run
        with `GET /v1/payroll-runs/{id}`.
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: payroll_run.corrected }
                    data:
                      type: object
                      properties:
                        payroll_run:
                          { $ref: "#/components/schemas/PayrollRunSummary" }
      responses:
        "2XX": { description: Acknowledged. }
  payroll_run.deleted:
    post:
      tags: [Webhooks]
      summary: A finalized payroll run was deleted and its journal reversed
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: payroll_run.deleted }
                    data:
                      type: object
                      required: [payroll_run_id]
                      properties:
                        payroll_run_id: { type: string }
      responses:
        "2XX": { description: Acknowledged. }
  attendance.rejected:
    post:
      tags: [Webhooks]
      summary: A stored clock event could not become attendance
      description: |
        Sent for events that were accepted but later could not be paired into a
        valid shift (e.g. a clock-out with no clock-in, or a shift over 16 hours),
        and for events still unmatched to an employee after 72 hours.
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: attendance.rejected }
                    data:
                      type: object
                      required: [external_id, reason]
                      properties:
                        external_id: { type: string }
                        event_id: { type: string }
                        reason:
                          type: string
                          enum:
                            [
                              orphan_clock_out,
                              orphan_clock_in,
                              shift_too_long,
                              overlapping_shift,
                              unmatched_employee_expired,
                            ]
                        detail: { type: string }
      responses:
        "2XX": { description: Acknowledged. }
  ping:
    post:
      tags: [Webhooks]
      summary: Test event sent by the "send test event" action
      x-release: v1.2
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEvent"
                - type: object
                  properties:
                    type: { const: ping }
      responses:
        "2XX": { description: Acknowledged. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: "ah_live_… | ah_test_…"
      description: |
        `Authorization: Bearer <key>`. Keys are created in Ahlan Hamad under
        **Integrations → API keys** by a company user with the
        `integrations.manage` permission (CEO or HR admin by default). A key
        belongs to exactly one company, carries a fixed set of scopes, and is
        shown once at creation.

        - `ah_live_…` keys work only for live companies.
        - `ah_test_…` keys work only for sandbox companies (fake data you can
          build against safely). Using the wrong kind returns `401`.

        **Sandbox, self-serve.** Anyone with an Ahlan Hamad login can press
        **Create sandbox company** (on `/developers` or Integrations → API
        keys) to get a private sandbox company pre-filled with fake employees
        and one finalized payroll run, then issue `ah_test_` keys for it. No
        contact with Ahlan Hamad is needed to start building.

        Scopes: `employees:read`, `payroll:read`, `attendance:read`,
        `attendance:write`, `webhooks:manage`. `GET /v1/company` needs none.

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: Opaque `next_cursor` from the previous page. Omit for the first page.
      schema: { type: string }
    EmployeeId:
      name: employee_id
      in: path
      required: true
      schema: { type: string }
    PayrollRunId:
      name: payroll_run_id
      in: path
      required: true
      schema: { type: string }

  headers:
    X-Request-Id:
      description: Unique ID for this request. Quote it when contacting support.
      schema: { type: string }
    X-RateLimit-Limit:
      description: Requests allowed per minute for this key (default 600).
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests left in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Seconds until the allowance is fully restored.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    BadRequest:
      description: Malformed request (bad query parameter, invalid JSON, unknown cursor).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthenticated:
      description: Missing, malformed, revoked or wrong-environment API key.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://ahlanhamad.com/developers/errors#unauthenticated
            title: Invalid API key
            status: 401
            code: unauthenticated
            request_id: req_01J9…
    Forbidden:
      description: |
        The key is valid but lacks the required scope (`insufficient_scope`), or
        the Partner API is not enabled for this company (`api_not_enabled`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            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…
    NotFound:
      description: No such resource in this company (or not visible to partners, e.g. a draft payroll run).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationFailed:
      description: The body is well-formed JSON but fails validation.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://ahlanhamad.com/developers/errors#validation_failed
            title: Request body failed validation
            status: 422
            code: validation_failed
            request_id: req_01J9…
            errors:
              - { path: "events[3].at", message: "must include a UTC offset" }
    RateLimited:
      description: Too many requests for this key.
      headers:
        Retry-After: { $ref: "#/components/headers/Retry-After" }
        X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
        X-RateLimit-Remaining:
          { $ref: "#/components/headers/X-RateLimit-Remaining" }
        X-RateLimit-Reset: { $ref: "#/components/headers/X-RateLimit-Reset" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      description: RFC 9457 (formerly 7807) problem details.
      required: [type, title, status, code, request_id]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code:
          type: string
          description: Stable machine-readable error code. Branch on this, not on `title`.
          enum:
            - invalid_request
            - unauthenticated
            - insufficient_scope
            - api_not_enabled
            - not_found
            - conflict
            - payload_too_large
            - validation_failed
            - rate_limited
            - internal_error
        request_id: { type: string }
        errors:
          type: array
          description: Field-level problems (for `validation_failed`).
          items:
            type: object
            required: [path, message]
            properties:
              path: { type: string }
              message: { type: string }

    Page:
      type: object
      required: [next_cursor]
      properties:
        next_cursor:
          type: [string, "null"]
          description: Pass as `cursor` to get the next page. `null` on the last page.

    Month:
      type: string
      pattern: "^[0-9]{4}-(0[1-9]|1[0-2])$"
      examples: ["2026-09"]

    Money:
      type: string
      pattern: "^-?[0-9]+(\\.[0-9]{1,3})?$"
      description: Decimal string in the currency's minor-unit precision.
      examples: ["1250.500"]

    CountryCode:
      type: string
      enum: [KW, SA, AE, QA, BH, OM]

    CurrencyCode:
      type: string
      description: ISO 4217.
      examples: [KWD, SAR, AED, QAR, BHD, OMR]

    LocalizedText:
      type: object
      required: [en]
      properties:
        en: { type: string }
        ar: { type: [string, "null"] }

    Company:
      type: object
      required: [id, name, country, currency, timezone, sandbox, entities]
      properties:
        id: { type: string }
        name: { $ref: "#/components/schemas/LocalizedText" }
        country:
          $ref: "#/components/schemas/CountryCode"
        currency:
          $ref: "#/components/schemas/CurrencyCode"
        timezone:
          type: string
          description: IANA timezone, e.g. `Asia/Kuwait`.
        sandbox:
          type: boolean
          description: True for sandbox companies (fake data, `ah_test_` keys).
        entities:
          type: array
          description: |
            Legal establishments. A company operating in several GCC countries
            has one per country; each has its own currency and payroll runs.
          items: { $ref: "#/components/schemas/Entity" }

    Entity:
      type: object
      required: [id, country, name, currency, primary]
      properties:
        id: { type: string }
        country: { $ref: "#/components/schemas/CountryCode" }
        name: { $ref: "#/components/schemas/LocalizedText" }
        currency: { $ref: "#/components/schemas/CurrencyCode" }
        primary: { type: boolean }

    Employee:
      type: object
      description: |
        Partner-safe employee record. Salary, bank, ID-document, date-of-birth,
        insurance-number and exit-reason data are never included.
      required:
        [
          id,
          employee_code,
          name,
          job_title,
          department,
          status,
          employment_type,
          country,
          hire_date,
        ]
      properties:
        id: { type: string }
        employee_code:
          type: [string, "null"]
          description: The company's own staff number, if it uses one.
        name: { $ref: "#/components/schemas/LocalizedText" }
        job_title: { $ref: "#/components/schemas/LocalizedText" }
        department: { type: string }
        status:
          type: string
          description: |
            `notice_period` — resignation or termination recorded, last day not
            yet reached. `left` — employment has ended (the reason is not
            exposed).
          enum: [active, probation, on_leave, suspended, notice_period, left]
        employment_type:
          type: string
          enum: [full_time, part_time, contract]
        country:
          $ref: "#/components/schemas/CountryCode"
        entity_id:
          type: [string, "null"]
        work_location:
          description: The branch / site the employee is assigned to, if any.
          oneOf:
            - type: "null"
            - type: object
              required: [id, name]
              properties:
                id: { type: string }
                name: { $ref: "#/components/schemas/LocalizedText" }
        manager_id:
          type: [string, "null"]
        email:
          type: [string, "null"]
          format: email
          description: Contact email on file (usually the work address).
        phone:
          type: [string, "null"]
          description: Contact phone on file, E.164 where available.
        hire_date: { type: string, format: date }
        termination_date:
          type: [string, "null"]
          format: date
        external_ids:
          type: object
          description: |
            Identifiers this person has in partner systems, keyed by partner app
            (e.g. `{"qompos": "cashier-0042"}`). Only the calling key's own app
            is included. Available from v1.1.
          additionalProperties: { type: string }

    PayrollRunSummary:
      type: object
      required:
        [
          id,
          month,
          period_start,
          period_end,
          status,
          country,
          currency,
          employee_count,
          totals,
        ]
      properties:
        id: { type: string }
        month: { $ref: "#/components/schemas/Month" }
        period_start: { type: string, format: date }
        period_end: { type: string, format: date }
        pay_date: { type: [string, "null"], format: date }
        status:
          type: string
          enum: [finalized, paid]
        country: { $ref: "#/components/schemas/CountryCode" }
        entity_id: { type: [string, "null"] }
        currency: { $ref: "#/components/schemas/CurrencyCode" }
        employee_count: { type: integer }
        totals:
          type: object
          required: [base_salary, allowances, deductions, net]
          properties:
            base_salary: { $ref: "#/components/schemas/Money" }
            allowances: { $ref: "#/components/schemas/Money" }
            deductions: { $ref: "#/components/schemas/Money" }
            net: { $ref: "#/components/schemas/Money" }
            employer_social_insurance: { $ref: "#/components/schemas/Money" }
            employer_dews:
              $ref: "#/components/schemas/Money"
              description: DIFC employee workplace savings (employer share). UAE DIFC only.
        last_corrected_at:
          type: [string, "null"]
          format: date-time
          description: Set if a correction changed this run after it was closed.

    PayrollRun:
      allOf:
        - $ref: "#/components/schemas/PayrollRunSummary"
        - type: object
          required: [components, journal]
          properties:
            components:
              type: array
              description: Run totals broken down by pay component. Components with a zero total are omitted.
              items: { $ref: "#/components/schemas/PayrollComponent" }
            journal:
              type: array
              description: |
                The journal entries Ahlan Hamad posted for this run: the
                `accrual` leg when the run was finalized, and the `payment` leg
                once it is marked paid. Each entry balances (sum of debits =
                sum of credits).
              items: { $ref: "#/components/schemas/JournalEntry" }

    PayrollComponent:
      type: object
      required: [type, total, employee_count]
      properties:
        type:
          type: string
          description: |
            New types may be added; treat unknown values as "other".
            `eosb_accrual` and `wps_bank_fees` appear once end-of-service
            accrual and wage-protection fees are included in the payroll journal.
          enum:
            - basic_salary
            - housing_allowance
            - transport_allowance
            - social_allowance
            - other_allowances
            - overtime
            - additions
            - absence_deductions
            - leave_deductions
            - loan_deductions
            - other_deductions
            - employee_social_insurance
            - employer_social_insurance
            - employer_dews
            - income_tax
            - eosb_accrual
            - wps_bank_fees
            - net_pay
        total: { $ref: "#/components/schemas/Money" }
        employee_count:
          type: integer
          description: Number of employees with a non-zero amount for this component.

    JournalEntry:
      type: object
      required: [id, leg, reference, date, currency, status, lines]
      properties:
        id: { type: string }
        leg:
          type: string
          enum: [accrual, payment]
        reference: { type: string, examples: ["PAY-KW-2026-09"] }
        date: { type: string, format: date }
        currency: { $ref: "#/components/schemas/CurrencyCode" }
        status:
          type: string
          enum: [posted, reversed]
        description: { $ref: "#/components/schemas/LocalizedText" }
        lines:
          type: array
          items: { $ref: "#/components/schemas/JournalLine" }

    JournalLine:
      type: object
      required: [account_code, account_name, debit, credit]
      properties:
        account_code:
          type: string
          description: Account code in the company's Ahlan Hamad chart of accounts (e.g. `5100`).
        account_name: { $ref: "#/components/schemas/LocalizedText" }
        account_type:
          type: string
          enum: [asset, liability, equity, revenue, expense]
        component:
          type: [string, "null"]
          description: The `PayrollComponent.type` this line comes from, when it maps to one.
        debit: { $ref: "#/components/schemas/Money" }
        credit: { $ref: "#/components/schemas/Money" }
        memo: { type: [string, "null"] }

    EmployeeRef:
      type: object
      required: [type, value]
      properties:
        type:
          type: string
          description: |
            - `external_id` — your own identifier for the person (recommended).
              HR links it to the employee once; after that it always matches.
            - `employee_id` — Ahlan Hamad's `Employee.id`.
            - `employee_code` — the company's staff number.
            - `email`, `phone` — matched against the employee's contact details.
          enum: [external_id, employee_id, employee_code, email, phone]
        value: { type: string }

    AttendanceEventInput:
      type: object
      required: [external_id, employee_ref, type, at, source]
      properties:
        external_id:
          type: string
          maxLength: 128
          description: Your unique ID for this event. Makes the call idempotent.
        employee_ref: { $ref: "#/components/schemas/EmployeeRef" }
        type:
          type: string
          enum: [clock_in, clock_out, break_start, break_end]
        at:
          type: string
          format: date-time
          description: When it happened, RFC 3339 **with** offset. Must not be more than 5 minutes in the future or 35 days in the past.
        branch_ref:
          type: string
          maxLength: 128
          description: Your identifier for the location.
        source:
          type: string
          description: Your system's name, fixed per integration (e.g. `qompos`).
          pattern: "^[a-z0-9_-]{2,32}$"
        meta:
          type: object
          description: Free-form, stored for support (max 2 KB). Never interpreted.
          additionalProperties: true

    AttendanceEventResult:
      type: object
      required: [external_id, status]
      properties:
        external_id: { type: string }
        event_id: { type: string }
        status:
          type: string
          description: |
            - `accepted` — stored and matched to an employee.
            - `duplicate` — already received; nothing changed.
            - `unmatched_employee` — stored; will be processed once HR links the person.
            - `rejected` — not stored; see `reason`.
          enum: [accepted, duplicate, unmatched_employee, rejected]
        reason:
          type: string
          description: Present when `status` is `rejected`.
          enum:
            [
              invalid_timestamp,
              timestamp_out_of_range,
              invalid_type,
              employee_left,
              period_locked,
            ]
        detail: { type: string }

    AttendanceDay:
      type: object
      required:
        [employee_id, date, status, worked_minutes, break_minutes, sources]
      properties:
        employee_id: { type: string }
        date: { type: string, format: date }
        status:
          type: string
          enum: [present, late, half_day, absent, on_leave, holiday]
        first_in: { type: [string, "null"], format: date-time }
        last_out: { type: [string, "null"], format: date-time }
        worked_minutes: { type: integer }
        break_minutes: { type: integer }
        sources:
          type: array
          description: Where the day's records came from.
          items:
            type: string
            examples: [qompos, employee_app, manual]

    WebhookEventType:
      type: string
      enum:
        - employee.created
        - employee.updated
        - employee.left
        - payroll_run.finalized
        - payroll_run.paid
        - payroll_run.corrected
        - payroll_run.deleted
        - attendance.rejected

    WebhookEndpointInput:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: "`https://` only; private and loopback addresses are refused."
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WebhookEventType" }
        description: { type: string, maxLength: 200 }

    WebhookEndpoint:
      type: object
      required: [id, url, events, active, created_at]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        description: { type: [string, "null"] }
        active:
          type: boolean
          description: |
            False after 7 days of failed deliveries, or when the API key that
            owns it is revoked. Re-enable with `PATCH` once fixed.
        disabled_reason:
          type: [string, "null"]
          enum: [delivery_failures, key_revoked, manual, null]
        created_at: { type: string, format: date-time }
        last_delivery:
          oneOf:
            - type: "null"
            - type: object
              properties:
                at: { type: string, format: date-time }
                status_code: { type: [integer, "null"] }
                ok: { type: boolean }

    WebhookDelivery:
      type: object
      required: [id, event_id, event_type, attempt, status, created_at]
      properties:
        id: { type: string }
        event_id: { type: string }
        event_type: { type: string }
        attempt:
          type: integer
          description: 1 for the first try; increments on each retry or redelivery.
        status:
          type: string
          enum: [succeeded, failed, pending]
        response_status_code:
          type: [integer, "null"]
          description: Your server's HTTP status, or null if the connection failed or timed out.
        response_body_excerpt:
          type: [string, "null"]
          description: First 1 KB of your response body.
        error:
          type: [string, "null"]
          description: Our side of a failure (timeout, DNS, TLS, connection refused).
        duration_ms: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        next_attempt_at:
          type: [string, "null"]
          format: date-time
          description: When we will retry, if we will.
        request:
          type: object
          description: Exactly what we sent.
          required: [headers, body]
          properties:
            headers:
              type: object
              additionalProperties: { type: string }
            body: { type: string }

    WebhookEvent:
      type: object
      description: |
        Envelope for every webhook delivery.

        **Verify every delivery.** Each request carries
        `X-AH-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>` where the HMAC
        is computed with the endpoint's secret over `<t>.<raw request body>`.
        Reject if the signature does not match or `t` is more than 5 minutes
        from your clock.

        **Delivery.** At least once, not necessarily in order. Deduplicate on
        `id`. Respond `2xx` within 10 seconds; otherwise we retry after 1 min,
        5 min, 30 min, 2 h, 12 h and 24 h. After 7 days of failures the
        endpoint is disabled and the company owner is emailed.
      required: [id, type, created_at, company_id, livemode, data]
      properties:
        id: { type: string, description: Unique event ID. }
        type: { type: string }
        created_at: { type: string, format: date-time }
        company_id: { type: string }
        livemode:
          type: boolean
          description: False for sandbox companies.
        data: { type: object }
