FairCheck HR in your Pocket

Webhooks

FairCheck supports both inbound webhooks (push payslip or award data to us)
and an outbound completion webhook (we notify you when an evaluation
finishes). All webhooks are HMAC-SHA256 signed and verified in constant time.

Outbound: payslip.completed

When a payslip finishes evaluating, FairCheck POSTs a non-PII notification to
your account's configured webhook URL (set on the Integrations page). The body
contains ids, status, and finding codes/shortfalls only — never names or other
identifying data.

{
  "event": "payslip.completed",
  "payslip_id": "payslip_abc123",
  "status": "completed",
  "compliance": {
    "summary_status": "needs_review",
    "status": "needs_review",
    "evaluated": true,
    "rule_engine_version": "2.1.0",
    "findings_count": 1,
    "findings": [ { "code": "MINIMUM_WAGE", "severity": "error", "shortfall": 42.30 } ]
  }
}

compliance.status is the verdict to automate on — it adds not_checked (no
award could be resolved, so the wage rules never ran) and cant_verify (the rate
covers hours we cannot place) to the three summary_status values. Routing on
summary_status alone files a not_checked payslip as passed, recording an
all-clear for one whose wages were never examined. See
the payslip endpoint for the full contract.

Accounts with the AI review layer enabled also receive an ai_review object
alongside compliance (never inside it); the key is absent for everyone else.
It carries a status of flagged or clear and zero or more reasons from a
closed set of codes. compliance stays the verdict of record — see
the payslip endpoint for the full contract.

payslip.held

A payslip that was stored but not checked sends the same body with
"event": "payslip.held", "status": "held", a hold_reason of no_seat or
unmatched, and no compliance object:

  • no_seat: the employee has no seat on the account's plan.
  • unmatched: no employee could be identified.

When the payslip is checked later, a normal payslip.completed follows.

Verifying the signature

The request carries X-Faircheck-Signature: sha256=<hex>, an HMAC-SHA256 of the
raw request body keyed with your account's webhook secret:

expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
Rack::Utils.secure_compare(expected, request.headers["X-Faircheck-Signature"])

Delivery retries with exponential backoff (up to 5 attempts). URLs that fail SSRF
safety checks are dropped, not retried.

Inbound: POST /webhooks/payroll

Push payslip / pay-run data to FairCheck from your payroll platform. Payloads are
ingested through the same idempotent path as the API and evaluated
asynchronously. Accepts { "payslips": [ ] }, { "payslip": { } }, or a single
bare object (max 100 payslips per delivery).

sequenceDiagram
    participant P as Payroll platform
    participant F as FairCheck
    P->>F: POST /webhooks/payroll<br/>X-Faircheck-Key / Timestamp / Signature
    F->>F: check timestamp (±5 min)
    F->>F: verify HMAC of "timestamp.body"<br/>(current or previous secret)
    alt invalid or stale
        F-->>P: 401 Unauthorized
    else verified
        F->>F: Payslips::Ingestor (idempotent, async eval)
        F-->>P: 202 { received, created }
    end

Sign each delivery with your account's inbound webhook secret (provisioned on the
Integrations page):

Header Value
X-Faircheck-Key whkey_… — identifies the sending account
X-Faircheck-Timestamp unix seconds; rejected if more than 5 minutes from now
X-Faircheck-Signature sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>">

The timestamp is part of the signed message, so a captured request cannot be
replayed later with a fresh timestamp. During a secret rotation, both the current
and previous secret verify. Invalid or stale requests receive 401; a valid
delivery returns 202 with { "received": n, "created": m }.

Inbound: POST /webhooks/mapd

Used by the FWC/MAPD award-data service to notify FairCheck that award data
changed. Signed with X-Mapd-Signature: sha256=<hex> over the raw body using the
shared MAPD secret. FairCheck extracts the affected MA###### award codes and
re-syncs them. Returns 202.