npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@craibuc/adp-workforce-now

v3.5.1

Published

TypeScript client for ADP Workforce Now: mTLS transport, lazy OAuth, typed errors. Runs on Bun and Node.

Readme

adp-workforce-now

TypeScript client for ADP Workforce Now: mTLS transport, lazy OAuth client-credentials auth, typed errors, paginated worker reads, and lifecycle events (hire / rehire / terminate).

Runs on Bun ≥ 1.2 (tested on 1.3) and Node ≥ 20. Zero runtime dependencies (one optional-integration peer, windmill-client, is only ever loaded by the ./windmill token store).

Install

npm install @craibuc/adp-workforce-now
# or
bun add @craibuc/adp-workforce-now

Usage

import { Client } from '@craibuc/adp-workforce-now';

// PEM strings may be raw or base64-encoded (auto-detected).
const client = new Client(certificatePem, privateKeyPem, {
  credentials: { client_id, client_secret }, // lazy auto-auth + 401 retry
});

// keyed lookup — one worker or undefined
const byAoid = await client.worker.get('G0FAKEFAKEFAKE1A');
const bySsn = await client.worker.get({ ssn: '123-45-6789' });

// search() is LAZY: it fetches nothing until you call a method on it
const search = client.worker.search({ familyName: 'Duck', status: 'A' });

const page = await search.page(0);       // one request — { workers, index, done, next }
for await (const p of search.pages()) {  // stream — one request per iteration
  console.log(p.workers.length, p.done);
}

Early-exit scan recipe (test each worker with arbitrary code, stop at the first hit — this is what the deprecated .find() does internally):

async function findByEmployeeNumber(id: string) {
  for await (const { workers } of client.worker.search({ status: 'A' }).pages()) {
    const match = workers.find((w) => (w.workerID as { idValue?: string } | undefined)?.idValue === id);
    if (match) return match;
  }
}

Flow-engine loops (e.g. a Windmill while-loop, one iteration per page): call search(query).page(n) each iteration and loop on donenever on workers.length, which can be 0 mid-stream when client-side residual filters (e.g. the second name in a two-name query) empty a page.

ADP requires mutual TLS on every call, including the token endpoint. Keys and certificates are kept in memory only.

Masked data

GET responses arrive with government IDs masked by default. To receive unmasked values (requires appropriate ADP scopes):

const client = new Client(cert, key, { credentials, masked: false });

Errors

All non-2xx responses throw a subclass of AdpError carrying statusCode, endpoint, ADP's human-readable adpMessage, machine-readable adpCode, and the raw body:

import { AdpError, BadRequestError } from '@craibuc/adp-workforce-now';

try {
  await client.worker.rehire({ associateOID, rehireDate, effectiveDate });
} catch (error) {
  if (error instanceof BadRequestError && error.adpCode === 'API_REHIRE_EE_ALREADY_ACTIVE') {
    // already active — treat as success
  } else {
    throw error;
  }
}

Event validation

Event POSTs are validated client-side against the event's metadata (GET /events/hr/v1/{event}/meta, cached (default 12 h)) before anything is sent — but only code-list violations block the request: a tenant reason/type code that isn't in the allowed list fails fast with a readable EventValidationError naming the allowed codes, instead of an opaque ADP 400. Other meta constraints (required, readOnly, hidden, pattern) are computable via eventMeta() + validateEnvelope() for diagnostics, but never block a POST — live verification against real tenant metas showed ADP overdeclares those constraints on fields that battle-tested envelopes have always sent successfully. On an ADP 400, the meta is refreshed once and re-checked (self-healing after tenant validation-table edits, still code-list-only); the request is never re-sent. When that self-heal upgrades a stale-cache failure into an EventValidationError, the error's cause is the original BadRequestError ADP returned. If an event's meta endpoint is itself unavailable (some tenants return errors for specific metas), validation is skipped and the request proceeds — the server remains the authority. Opt out with:

const client = new Client(cert, key, { credentials, validateEvents: false });

Any worker.* event not yet wrapped can use the same pipeline directly:

await client.worker.postEvent('worker.work-assignment.modify', envelope);

Event notifications

ADP's event-notification queue delivers subscribed events one message at a time: next() returns the head of the queue — the same message until you acknowledge it — and delete(messageId) is the acknowledgment that makes the next message available. Delete after successful processing and you get at-least-once semantics. Empty values are null (not undefined) so flow-step results survive JSON serialization.

// Process up to 50 queued notifications:
for (let i = 0; i < 50; i++) {
  const message = await client.eventNotifications.next();
  if (message === null) break;              // queue empty
  await handle(message.payload);            // your logic
  await client.eventNotifications.delete(message.messageId); // ack -> advances queue
}

In a Windmill flow, each iteration can be its own loop step — the queue holds the position, so there is no index to carry between iterations.

Header-dependent endpoints like this one are built on the client.raw escape hatch (raw(method, path, data?){ status, headers, body }), which is public and carries the same auth, mTLS, retry, and typed-error semantics as get/post.

Worker photos

const photo = await client.worker.getPhoto(aoid);   // { contentType, bytes } | null (none on file)

await client.worker.setPhoto({
  associateOID: aoid,
  image: bytes,                 // Uint8Array, or a base64 string (auto-decoded)
  // contentType/filename optional — the type is sniffed from the image bytes
});

setPhoto checks the image size against your tenant's limit (from the upload event's metadata) before sending anything — an oversized photo fails fast with both numbers in the error. The library does not resize images (that needs an image codec); in your own script:

import sharp from 'sharp'; // consumer dependency, e.g. in a Windmill step
const resized = await sharp(original).resize({ width: 400 }).jpeg({ quality: 80 }).toBuffer();
await client.worker.setPhoto({ associateOID: aoid, image: new Uint8Array(resized) });

Crossing Windmill flow-step boundaries: photos are binary, flow results are JSON — convert with Buffer.from(photo.bytes).toString('base64') on the way out, and pass the base64 string straight back into setPhoto.

Worker onboarding

onboard() starts an ADP onboarding (Applicant Onboarding v2) for a new hire. Unlike other events, the body is validated against your tenant's metadata for required fields as well as code lists before anything is posted — a missing required field fails immediately with EventValidationError, naming the path (when the tenant meta is unavailable, including during its 5-minute negative-cache window, the call falls back to posting unvalidated and ADP then enforces server-side).

await client.worker.onboard({
  onboardingTemplateCode: 'YOUR-TEMPLATE',
  personal: {
    givenName: 'First', familyName: 'Last',
    ssn: '111-22-3333',
    address: { lineOne: '1 Main St', cityName: 'Town', stateCode: 'MN', postalCode: '55555' },
    mobilePhone: '(612) 555-9876', email: '[email protected]',
  },
  worker: { hireDate: '2026-08-01', jobCode: 'J01', homeDepartmentCode: '000001' },
  payroll: { payrollGroupCode: 'ABC', payCycleCode: 'B' },
  tax: { federal: { taxFilingStatusCode: 'S', dependents: 2 } },
  // tenant-specific extras: payroll.customCodeFields and a deep-merged
  // `overrides` object (applied before validation)
});

Token stores

Tokens are cached via a pluggable TokenStore (default: in-memory). The cached JSON shape is {"access_token": string, "expires_at": number} with expires_at in epoch seconds UTC — a cross-language contract shared with other clients.

On Windmill, share one token across scripts:

import { WindmillTokenStore } from '@craibuc/adp-workforce-now/windmill';

const client = new Client(cert, key, {
  credentials,
  tokenStore: new WindmillTokenStore('f/adp/access_token_cache'),
});

API coverage

Endpoint → Class.method mapping. Worker methods are reached via client.worker; anything not wrapped yet is reachable through the Client.get / Client.post escape hatches (auth, mTLS, and error extraction still apply).

| Status | ADP endpoint | Library API | Description | |:---:|---|---|---| | ✅ | POST accounts.adp.com/auth/oauth/v2/token | Client.authenticate | OAuth client-credentials token over mTLS; called lazily on any request, cached in the TokenStore, refreshed 300 s before expiry | | ✅ | GET /hr/v2/workers ($top/$skip, single $filter predicate) | Worker.searchpage / pages (all / find deprecated) | Lazy search handle; stateless WorkerPage {workers, index, done, next} protocol; extra query fields filtered client-side (ADP's compound name filters are broken server-side) | | ✅ | GET /hr/v2/workers/{aoid} | Worker.get | Fetch a single worker by associate OID (string or { aoid }) | | ✅ | POST /events/hr/v1/worker.read | Worker.get({ ssn }) | Look up a single worker by government ID; ADP's read-event filter supports IDs but not name paths | | ✅ | POST /events/hr/v1/worker.hire | Worker.hire | Hire a new worker (legal name, SSN, address, hire date, payroll group) | | ✅ | POST /events/hr/v1/worker.rehire | Worker.rehire | Rehire a terminated worker as of an effective date | | ✅ | POST /events/hr/v1/worker.work-assignment.terminate | Worker.terminate | Terminate a work assignment (reason code, termination/last-worked date, eligibility indicators) | | ✅ | any other endpoint | Client.get, Client.post | Escape hatch for unwrapped endpoints — auth, mTLS, and typed-error extraction still apply | | ✅ | any endpoint (with headers) | Client.raw | Escape hatch returning { status, headers, body } — same auth/retry/error semantics | | ✅ | POST /events/hr/v1/worker.work-assignment.base-remuneration.change | Worker.changeBaseRemuneration | Change a worker's pay rate (hourly/daily/salary) as of an effective date | | ✅ | GET /events/hr/v1/{event}/meta · GET /hcm/v2/applicant.onboard/meta | Worker.eventMeta (+ Worker.postEvent pipeline) | Event metadata for any worker.* event plus applicant.onboard, cached (default 12 h); powers client-side envelope validation | | ✅ | POST /events/hr/v1/worker.legal-name.change | Worker.changeLegalName | Change a worker's legal name as of an effective date | | ✅ | POST /events/hr/v1/worker.person.custom-field.string.change | Worker.changeCustomFieldString | Change a string-typed custom field on a worker's record | | ✅ | POST /events/hr/v1/worker.leave.absence.request | Worker.requestLeaveAbsence | Request a leave of absence (leave-type code, start/expected-return dates) | | ✅ | POST /hcm/v2/applicant.onboard | Worker.onboard | Start an onboarding for a new hire (grouped personal/worker/payroll/tax params; required-field validation blocks pre-POST) | | ✅ | GET /core/v1/event-notification-messages | EventNotifications.next | Head of the event-notification queue ({ messageId, payload }, null when empty); same message until deleted | | ✅ | DELETE /core/v1/event-notification-messages/{id} | EventNotifications.delete | Acknowledge a message (echoes the deleted record); advances the queue | | ✅ | GET /hr/v2/workers/{aoid}/worker-images/photo | Worker.getPhoto | Worker photo as bytes + content type; null when none on file | | ✅ | POST /events/hr/v1/worker.photo.upload | Worker.setPhoto | Multipart photo upload with tenant-meta size preflight and jpeg/png type sniff | | ⬜ | POST /events/hr/v1/worker.work-assignment.modify | roadmap | Modify an existing work assignment | | ⬜ | contact-info change events | roadmap | Change a worker's phone/email contact info | | ⬜ | address change events | roadmap | Change a worker's home/mailing address | | ⬜ | POST /events/hr/v1/worker.pay-distribution.change | roadmap | Change a worker's pay distribution (direct deposit accounts) | | ⬜ | legacy worker-profile v1 PUT endpoints | not planned | Superseded by the event-based writes above |

✅ implemented (2.0.0–3.3.0) · 🔜 planned (version noted per row) · ⬜ roadmap / no current plans — PRs welcome

Development

bun install
bun test            # unit tests (Bun)
npm run test:node   # Node adapter tests against a local mTLS server
bun run typecheck

Live integration test

Copy .env.sample to .env and fill in real ADP credentials (base64-encode the PEMs). The live test then runs as part of bun test; without the variables it is skipped.

License

ISC