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.