REST API Payroll Sync
A REST endpoint that returns a pay run looks like the easiest payroll interface to consume and is the one most likely to double-pay an employee, and REST API payroll sync — part of the Multi-Format Payroll Data Ingestion & Normalization framework — exists to make that real-time channel as disciplined as the batch ones. Unlike a flat file that arrives once, a vendor-hosted HRIS or payroll API exposes mutable, paginated, rate-limited state behind an OAuth handshake: a token expires mid-batch, a 429 truncates a page, a webhook re-fires, and a naive client either drops records or replays them into a second disbursement. The job of this sync is to extract, validate, and normalize compensation, tax, and benefits payloads into the same canonical record the rest of the pipeline already understands — with strict concurrency control, schema enforcement at the boundary, and an immutable audit trail that maps every source payload to the normalized row it produced.
This pattern sits at the ingestion edge, downstream of credential provisioning and upstream of the gross-to-net calculators. It does not compute pay; it decides which API responses are allowed to reach the calculator, what canonical shape they take, and how a failed fetch degrades without halting payroll. Every response is treated as untrusted until it validates against the canonical schema, and re-running a sync over the same pay run must converge on identical records rather than a duplicate paycheck.
Data Normalization & Boundary Enforcement
A REST payload is JSON, which means it carries types, but they are the wrong types for payroll. JSON numbers deserialize to IEEE-754 floats, optional fields appear and vanish between API versions, vendors nest the same datum under grossPay, gross_pay, or compensation.gross, and a custom-field block can inject keys no contract anticipated. The first responsibility of the sync is boundary enforcement: validate the shape and every field’s type before a record materializes, and reject — never coerce — anything that does not satisfy the contract. This is the same boundary discipline formalized in Data Boundary Definitions, applied to the failure surface of an HTTP/JSON interface.
The input contract for a synced payroll record is small and non-negotiable. Every accepted record must resolve to:
employee_id— a non-empty, trimmed identifier. A missing or blank value is a quarantine condition, never a generated placeholder.pay_period_start/pay_period_end— ISO-8601 dates wherestart <= end. A reversed or unparseable window is rejected; a sync that swaps them to “fix” the order corrupts proration silently.gross_pay/tax_withheld/net_pay— parsed asDecimal, never left as thefloatthe JSON decoder produced. This decimal precision requirement is what stops a fraction of a cent of binary rounding from compounding across a pay run into a reconciliation break against the vendor’s own totals.jurisdiction— a two-character authority code that must resolve against a known jurisdiction table. An unresolved code is quarantined; it is never silently defaulted to federal.
Two corruption classes are specific enough to a REST interface to deserve explicit handling at the boundary. The first is the float trap: json.loads('{"gross_pay": 0.1}') yields a binary float, and any model that types gross_pay as float has already lost precision before validation runs. The fix is to parse monetary fields through Decimal(str(value)) — going through the string form preserves the digits the vendor sent. Pydantic’s Decimal field does this when the raw value is read as a string; the safest contract is to request the payload with the money fields as strings where the vendor supports it, and to cast defensively otherwise. The second is schema drift across API versions: a provider adds a direct_deposit block, renames withholding to tax_withheld, or changes jurisdiction from a code to an object. A client that reads by assumed key shape breaks the instant the contract moves; validation against a typed model with an explicit version pin turns that drift into a logged rejection rather than a silent miswithholding.
Sensitive identifiers carry their own boundary rules. Social Security Numbers, bank account and routing numbers, and exact salary figures must be format-validated on entry and masked in any transit log the moment they are read, so a debug line never persists a raw SSN to disk. PII is encrypted at rest and in transit; field-level or envelope encryption is applied before persistence, with key rotation governed by NIST SP 800-57 Part 1 Rev. 5 and jurisdictional residency mandates (GDPR, CCPA, and state payroll-privacy statutes). The canonical output schema this stage produces is deliberately identical to the one emitted by CSV Ingestion Pipelines and EDI 834 Parsing, so a record’s downstream treatment never depends on whether it arrived as a file or a webhook.
Authentication and credential lifecycle
Token acquisition must be decoupled from data fetching. Payroll providers enforce OAuth 2.0 client-credentials or PKCE flows, often layered with mutual TLS for enterprise tenants, and a token that expires mid-batch must refresh transparently rather than surfacing as a 401 that drops a page. Cache the access token in a secure in-memory store with explicit TTL, and refresh it a margin before expiry — sixty seconds is a safe default — so no request races the boundary. Never embed secrets in pipeline configuration: pull them from an environment-bound secret manager (AWS Secrets Manager, HashiCorp Vault) under least-privilege IAM. Log token acquisition events, granted scopes, and rotation timestamps, but exclude the raw token; hash or truncate an identifier for traceability per the guidance in RFC 6749.
Jurisdictional Resolution & Effective Dating
A two-character jurisdiction code tells you where an employee is taxed; it does not tell you which rule version governs the period the API returned. REST syncs are not immune to retroactive data: a vendor exposes a corrected March pay run in April, or a webhook replays an adjustment after a rule change. Binding such a record to the rule in force on the run date instead of the period date is the most common way a structurally clean sync still produces a wrong number.
The override hierarchy is most-protective-wins, evaluated municipal first, then state, then federal:
Municipal > State > Federal
A municipal local-tax or minimum-wage rule supersedes the state rule, which supersedes the federal baseline — but only for the jurisdiction tied to the record and only for a rule whose effective window contains the pay period. This is the same precedence the FLSA Threshold Mapping gate applies when resolving exempt status, so a default selected at ingestion can never contradict the threshold the calculation engine applies later.
Effective windows are half-open so adjacent rule versions never both claim a boundary date. A rule is in force for an evaluation date when:
\text{effective\_start} \le d < \text{effective\_end}with a missing effective_end modeled as . Resolution selects against pay_period_start, never datetime.now(). The canonical selection is a single indexed query:
SELECT rule_id
FROM jurisdiction_rules
WHERE jurisdiction = :jurisdiction
AND effective_start <= :pay_period_start
AND (effective_end IS NULL OR :pay_period_start < effective_end)
ORDER BY authority_rank DESC -- municipal=3, state=2, federal=1
LIMIT 1;
Overlap detection belongs at rule-load time, not run time. If two versions of the same jurisdiction rule both claim a date — overlapping [start, end) windows — the rule set must fail to load rather than letting a sync pick arbitrarily between them. That converts a non-deterministic payroll bug, nearly impossible to reproduce from an API replay, into a deploy-time error caught where it is cheap to fix.
Production Implementation Pattern
The client below uses token caching with pre-expiry refresh, cursor-based pagination, exponential backoff with jitter for 429 and 5xx, strict validation that casts every monetary field through Decimal, a deterministic idempotency key, and explicit fallback routing for unprocessable records. It emits structured key=value logs that are copy-paste safe for production, never logs a raw token or PII, and follows PEP 8. It is runnable against any provider that exposes a cursor-paginated records endpoint and an OAuth token endpoint.
"""Deterministic REST API payroll sync with Decimal-safe normalization."""
import hashlib
import json
import logging
import random
import time
from dataclasses import dataclass
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation, getcontext
from typing import Any, Optional
from urllib.parse import urljoin
import requests
from pydantic import BaseModel, Field, ValidationError, field_validator
# Fixed-point context for all monetary arithmetic — never float.
getcontext().prec = 28
getcontext().rounding = ROUND_HALF_UP
logger = logging.getLogger("payroll.rest_sync")
DATE_FORMAT = "%Y-%m-%d"
class PayrollSyncError(Exception):
"""Base exception for REST sync failures."""
class PayrollRecord(BaseModel):
"""Canonical record contract. Money is Decimal, parsed from the
raw value via str() so JSON binary floats never enter state."""
employee_id: str = Field(..., min_length=1)
pay_period_start: date
pay_period_end: date
gross_pay: Decimal = Field(..., ge=0)
tax_withheld: Decimal = Field(..., ge=0)
net_pay: Decimal = Field(..., ge=0)
jurisdiction: str = Field(..., pattern=r"^[A-Z]{2}$")
@field_validator("gross_pay", "tax_withheld", "net_pay", mode="before")
@classmethod
def _decimal_from_str(cls, v: Any) -> Decimal:
# Route through str() so 0.1 keeps its digits instead of the
# binary-float approximation the JSON decoder produced.
try:
return Decimal(str(v))
except InvalidOperation as exc:
raise ValueError(f"non_decimal_money value={v!r}") from exc
@field_validator("pay_period_end")
@classmethod
def _ordered_window(cls, v: date, info: Any) -> date:
start = info.data.get("pay_period_start")
if start is not None and v < start:
raise ValueError("pay_period_end precedes start")
return v
@dataclass(frozen=True)
class JurisdictionRule:
rule_id: str
jurisdiction: str
authority_rank: int # municipal=3, state=2, federal=1
effective_start: date
effective_end: Optional[date] = None
def in_force(self, on: date) -> bool:
if on < self.effective_start:
return False
return self.effective_end is None or on < self.effective_end
class PayrollSyncClient:
def __init__(
self,
base_url: str,
client_id: str,
client_secret: str,
jurisdiction_rules: list[JurisdictionRule],
token_ttl: int = 3600,
refresh_margin: int = 60,
) -> None:
self.base_url = base_url
self.client_id = client_id
self.client_secret = client_secret
self.jurisdiction_rules = jurisdiction_rules
self.token_ttl = token_ttl
self.refresh_margin = refresh_margin
self._token_cache: dict[str, Any] = {}
self.session = requests.Session()
def _acquire_token(self) -> str:
"""Return a cached token; refresh before expiry, never mid-page."""
now = time.time()
if self._token_cache.get("expires_at", 0) > now + self.refresh_margin:
return self._token_cache["access_token"]
logger.info("event=token_acquire flow=client_credentials")
resp = requests.post(
urljoin(self.base_url, "/oauth/token"),
json={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
self._token_cache = {
"access_token": data["access_token"],
"expires_at": now + data.get("expires_in", self.token_ttl),
}
logger.info("event=token_cached ttl=%s", data.get("expires_in", self.token_ttl))
return self._token_cache["access_token"]
def _fetch_page(self, cursor: Optional[str]) -> dict[str, Any]:
"""Fetch one page with backoff+jitter on 429/5xx; never drop a page."""
token = self._acquire_token()
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
params: dict[str, Any] = {"limit": 100}
if cursor:
params["cursor"] = cursor
for attempt in range(5):
try:
resp = self.session.get(
urljoin(self.base_url, "/v1/payroll/records"),
headers=headers,
params=params,
timeout=30,
)
if resp.status_code == 429 or resp.status_code >= 500:
wait = min(2 ** attempt, 30) + random.uniform(0, 0.5)
logger.warning(
"event=backoff status=%s attempt=%s wait=%.2f",
resp.status_code, attempt + 1, wait,
)
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
except requests.RequestException as exc:
wait = min(2 ** attempt, 30) + random.uniform(0, 0.5)
logger.error(
"event=network_failure attempt=%s err=%s wait=%.2f",
attempt + 1, exc, wait,
)
time.sleep(wait)
raise PayrollSyncError("exhausted_retries endpoint=/v1/payroll/records")
@staticmethod
def _idempotency_key(payload: dict[str, Any]) -> str:
"""Deterministic key over the raw payload; re-sync yields the same key."""
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def _resolve_rule(self, jurisdiction: str, period_start: date) -> str:
"""Most-protective-wins, in force on period_start (never now())."""
candidates = [
r for r in self.jurisdiction_rules
if r.jurisdiction == jurisdiction and r.in_force(period_start)
]
if not candidates:
raise ValueError(
f"no_rule jurisdiction={jurisdiction} period_start={period_start}"
)
return max(candidates, key=lambda r: r.authority_rank).rule_id
def _route_to_fallback(self, payload: dict[str, Any], key: str, reason: str) -> None:
"""Append to the dead-letter queue with full context; never halt the run."""
envelope = {
"idempotency_key": key,
"reason": reason,
"captured_at": datetime.utcnow().isoformat(),
"raw_payload": payload,
}
logger.warning("event=fallback key=%s reason=%s", key[:12], reason)
# In production: dead_letter_queue.send(json.dumps(envelope))
_ = envelope
def sync_pay_run(self) -> dict[str, int]:
cursor: Optional[str] = None
validated = fallback = 0
seen: set[str] = set()
logger.info("event=sync_start endpoint=/v1/payroll/records")
while True:
page = self._fetch_page(cursor)
cursor = page.get("next_cursor")
for record in page.get("data", []):
key = self._idempotency_key(record)
if key in seen: # webhook/replay dedup
logger.info("event=duplicate_skip key=%s", key[:12])
continue
seen.add(key)
try:
parsed = PayrollRecord(**record)
rule_id = self._resolve_rule(parsed.jurisdiction, parsed.pay_period_start)
logger.info(
"event=validated key=%s emp=%s gross=%s rule=%s",
key[:12], parsed.employee_id, parsed.gross_pay, rule_id,
)
validated += 1
# In production: sink.upsert(key, parsed, rule_id)
except ValidationError as exc:
self._route_to_fallback(record, key, f"schema_validation: {exc}")
fallback += 1
except ValueError as exc:
self._route_to_fallback(record, key, str(exc))
fallback += 1
if not cursor:
break
logger.info("event=sync_done validated=%s fallback=%s", validated, fallback)
return {"validated": validated, "fallback": fallback}
Three properties make this safe in production. Monetary fields are Decimal from the first cast inward — the mode="before" validator routes the JSON value through str() so no binary float survives — and the period dates are parsed strictly, so nothing ambiguous reaches calculation. The idempotency key is a pure function of the sorted raw payload, so a replayed webhook or a re-run over the same pay run produces an identical key and the in-run seen set (backed by a durable store in production) makes the upsert a no-op rather than a second paycheck. And jurisdiction resolution runs against pay_period_start and ranks by authority, so effective dating and the override hierarchy are enforced in one place. For provider-specific backoff tuning, sliding-window request accounting, and circuit-breaker thresholds under throttling, see syncing payroll APIs with rate limiting; for overlapping the paginated I/O with database upserts at volume, the same client feeds Async Batch Processing.
Compliance Verification & Fallback Routing
Shipping the sync without a gate suite turns the dead-letter queue from a safety valve back into a silent failure mode. Run the following checklist in CI and against a per-run reconciliation job before any record reaches the payroll ledger.
- Decimal precision checks. Assert
gross_pay,tax_withheld, andnet_payareDecimalafter parsing, that a JSON value of0.1is stored as exactlyDecimal("0.1")and not its binary-float approximation, and that no code path routes money throughfloat(). Reconcile a synthetic pay run to the cent against the vendor’s reported control total; per IRS Publication 15-T the withholding tables assumeROUND_HALF_UP, not banker’s rounding. - Token-lifecycle tests. Simulate a token expiring mid-batch and assert
_acquire_tokenrefreshes before the next page rather than returning a401; assert the raw token never appears in any log line, and that scope and rotation events are logged by identifier only. - Pagination and backoff tests. Mock a
429followed by a200and assert the page is retried, not dropped; assertnext_cursoris followed to exhaustion so no page is skipped, and that the backoff includes jitter so concurrent workers do not synchronize their retries into a thundering herd. - Idempotency and replay checks. Feed the same payload twice — once inline in a page, once as a replayed webhook — and assert one validated record and one
event=duplicate_skip. Confirm duplicate payloads yield identicalidempotency_keyhashes and never trigger a second financial posting. This is the same idempotent ingestion property the whole framework depends on. - Effective-date drift tests. Resolve the same jurisdiction for a
pay_period_startinside, exactly on, and one day outside each rule window. Confirm half-open behavior — theeffective_startdate resolves, theeffective_enddate falls through to the next version — and that resolution binds to the period start, never the run clock. - Override-hierarchy tests. With a municipal, state, and federal rule all in force for one date, assert the resolver returns the municipal
rule_id; remove it and assert fallback to state, then federal. Feed two overlapping windows for one jurisdiction and assert the rule set refuses to load. - Fallback activation and dead-letter integrity. Inject a record with an unmapped jurisdiction, a missing
employee_id, a negativegross_pay, a reversed pay period, and a non-numeric money string. Assert each lands in the dead-letter queue with a distinctreasonplus its cursor context, and that the run still completes. The unmapped-jurisdiction case is the handoff into the broader Fallback Routing Strategies tier hierarchy. - PII-redaction audit. Grep the run logs and assert zero raw SSNs, bank routing numbers, or exact salary figures; correlation must be possible only by
idempotency_keyandemployee_id. Validate that field-level encryption keys rotate on schedule and that ciphertext is never persisted beside a plaintext key. - Audit reconciliation and retention. Re-sync an unchanged pay run and assert identical idempotency keys; write the run summary to a write-once store and map each key to an audit-evidence record. Retain payloads and dead-letter artifacts for the federal wage-record minimum under 29 CFR § 516.5 (three years) and the IRS employment-tax minimum of four years — most shops standardize on seven years to cover both.
When the endpoint is unavailable, exceeds rate limits past the retry budget, or returns structurally incompatible payloads, the sync degrades without halting downstream payroll: unprocessable records are written to the dead-letter queue with full context (headers, cursor state, validation error), operations is alerted with a severity classification, and a reconciliation checkpoint runs before any recovered record merges into the canonical ledger. For organizations running hybrid data contracts, legacy flat files can carry the period through CSV Ingestion Pipelines and structured benefit enrollments through EDI 834 Parsing until the REST endpoint stabilizes.
Failure Modes & Gotchas
- JSON float entering through the model. Typing
gross_payasfloat(or letting the decoder hand a binary float straight to aDecimalfield) reintroduces rounding, and the run stops reconciling to the vendor’s control total. Root cause: trusting the JSON number type for money. Fix: parse every monetary field throughDecimal(str(value))in amode="before"validator, exactly as the implementation above does. - Token expiry mid-batch. A token cached without a refresh margin expires between page three and four, the next fetch returns
401, and a client that treats it as fatal drops half the pay run. Root cause: coupling token TTL to batch duration. Fix: refresh a margin before expiry and re-acquire transparently inside the fetch loop. - Replayed webhooks double-paying. A provider re-fires a delivery after a timeout, or a paginated run overlaps a webhook, and the same record posts twice. Root cause: appending records without a dedup key. Fix: compute a deterministic idempotency key over the raw payload and upsert on it so a replay is a logged no-op.
- Silent default to federal on an unresolved jurisdiction. A blank or unknown
jurisdictionis treated as “use federal,” which underwithholds in a state with a higher floor and creates wage-and-hour exposure. Root cause: defaulting an unresolved key instead of quarantining it. Fix: an unresolved jurisdiction is a fallback condition; rule resolution runs only for a code that matches a known authority. - Resolving rules against
now(). A March pay run exposed in April binds to April’s rule, silently rewriting history on a retroactive adjustment. Root cause: using the run clock instead ofpay_period_start. Fix: pass the period start into rule resolution; the run clock never touches it.
Frequently Asked Questions
The vendor returns money as JSON numbers — how do I keep Decimal precision?
By the time json.loads runs, a value like 0.1 is already a binary float, so the digits the vendor intended are not perfectly recoverable. Two defenses: where the provider supports it, request the payload with monetary fields serialized as strings (many payroll APIs offer a string money mode precisely for this), and always cast through Decimal(str(value)) in a mode="before" validator. Going through the string form recovers the shortest decimal that round-trips the float, which for typical two-decimal currency values is the correct amount. Never feed the raw float straight into a Decimal field.
How do I make the sync idempotent when the API has no stable record ID?
Derive the key yourself: hash the canonicalized raw payload (json.dumps(..., sort_keys=True) then SHA-256). The same record produces the same key on every fetch, replay, or re-run, so the sink upsert becomes a no-op on collision. If the vendor does expose a stable line ID, prefer composing the key from (employee_id, pay_period_start, pay_period_end, line_id) so a legitimate correction to the amount produces a new payload hash but still maps to the same logical line for supersession rather than duplication.
What belongs in the dead-letter queue versus a hard run halt?
Individual bad records — schema drift, an unmapped jurisdiction, a non-numeric amount — go to the dead-letter queue so the rest of the pay run keeps moving. The run halts only when the channel itself is broken: the token endpoint is down, the retry budget for a page is exhausted, or the dead-letter rate crosses an alert threshold that signals a vendor-wide contract change rather than a few bad rows. Record-level isolation with run-level circuit breaking — one ambiguous payload must never stop payroll, but a systemic API change must never be processed past on a guess.
How do I avoid getting rate-limited or IP-blocked by the provider?
Throttle to the provider’s published SLA with a sliding window, back off on 429 and 5xx with exponential delay plus jitter so concurrent workers do not synchronize their retries, and trip a circuit breaker after consecutive failures rather than hammering a degraded endpoint. Honor any Retry-After header the provider returns in preference to your own backoff curve. The full treatment — window sizing, breaker thresholds, and cursor caching across runs — is in the rate-limiting guide linked below.
Does REST sync need the same canonical schema as CSV and EDI ingestion?
Yes, and that is the point. REST sync, CSV, and EDI 834 all emit the identical PayrollRecord shape so downstream calculation, validation, and audit logic never branch on the source channel. If the schemas diverge you end up maintaining three slightly different calculators and three ways to be wrong. A uniform output contract lets one set of verification gates cover every ingestion vector, regardless of whether a record arrived as a file, a benefit transmission, or a webhook.
Related
- Syncing payroll APIs with rate limiting — backoff curves, sliding windows, and cursor caching for exactly-once delivery under throttling.
- CSV Ingestion Pipelines — the flat-file vector that emits the same canonical record schema.
- EDI 834 Parsing — carrier benefit-file state machines that converge on the same boundary contract.
- Async Batch Processing — bounded concurrency and dead-letter routing for high-volume paginated syncs.
- Data Boundary Definitions — the canonical-record contract every synced payload must satisfy.