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

@argxi/workbase

v0.2.0

Published

Official TypeScript/JavaScript client SDK for the Workbase job marketplace and task-execution API.

Readme

@argxi/workbase

Official TypeScript/JavaScript client for the Workbase multi-tenant job marketplace and task-execution API. Zero runtime dependencies; runs on Node.js 22+, Bun, Deno and modern browsers.

Workbase is where employers fund work up front and workers are paid once it is approved. That money flow is the thing to understand before anything else, so it comes first below.


Contents


Install

npm install @argxi/workbase

Requires Node.js 22+ (for the global fetch and Web Crypto). No other runtime dependencies. The package is ESM-only; require("@argxi/workbase") works on Node.js versions that support require(esm) (22.12+), otherwise use import.


Quickstart

import { WorkbaseClient } from "@argxi/workbase";

const workbase = new WorkbaseClient({
  apiKey: process.env.WORKBASE_API_KEY!,      // wb_<keyId>_<secret>
  baseUrl: "https://acme.workbase.example/api" // defaults to http://localhost:3000/api
});

// 1. Fund a job before it can be published.
const funding = await workbase.payments.fund({
  purpose: "job_funding",
  jobId,
  amountMinor: 25_000,                        // minor units — 25_000 = 250.00
  currency: "USD",
  idempotencyKey: `fund-${jobId}`,
});
// Send the payer to funding.checkoutUrl. Escrow only counts once the provider
// confirms — a job cannot be published on an unpaid intent.

await workbase.jobs.publish(jobId);

The client is server-to-server only. It authenticates with an API key, not a session cookie; browsers should use your own backend as the intermediary.

Approved publisher: display jobs on your website

An approved publisher credential is also accepted by the client. Keep it in your server-side secret store and use only the read-only publisher resource:

const publisher = new WorkbaseClient({
  apiKey: process.env.WORKBASE_PUBLISHER_CREDENTIAL!,
  baseUrl: "https://app.example.com/api",
});

const page = await publisher.publisher.jobs({ pageSize: 20, difficulty: "easy" });
for (const job of page.items) {
  // Render the Workbase attribution and link task actions to job.workbaseUrl.
  console.log(job.title, job.workbaseUrl);
}

Publisher credentials require a reviewed application and DNS proof. They are read-only; workers must follow workbaseUrl and sign in to Workbase to claim or submit tasks. Never expose the credential in browser code.


Implementation guide

Employer: create, fund, publish

import { WorkbaseClient } from "@argxi/workbase";
import { readFile } from "node:fs/promises";

const workbase = new WorkbaseClient({ apiKey: process.env.WORKBASE_API_KEY! });

// 1. Upload the material the template's stimuli expect.
const reference = await workbase.files.upload({
  data: await readFile("./reference.jpg"),
  filename: "reference.jpg",
  contentType: "image/jpeg",
  purpose: "job_attachment",
});

// 2. Create a draft. `taskContent` is keyed by the template's stimulus ids.
const { jobId } = await workbase.jobs.create({
  title: "Verify 500 ID photos",
  description: "Compare each capture against its reference photo.",
  templateVersionId: process.env.TEMPLATE_VERSION_ID!,
  rewardMinor: 5_000,
  currency: "USD",
  assignmentCapacity: 500,
  taskContent: { reference_photo: reference.fileId },
  acceptanceCriteria: "Verdict is correct and the rationale names what was compared.",
  externalRef: "batch-B17", // your own idempotency/reconciliation key
});

// 3. Fund escrow. The stable key makes a retried call harmless.
const funding = await workbase.payments.fund({
  purpose: "job_funding",
  jobId,
  amountMinor: 2_500_000, // rewardMinor × assignmentCapacity
  currency: "USD",
  idempotencyKey: `fund-${jobId}`,
});
if (funding.checkoutUrl) redirectThePayerTo(funding.checkoutUrl);

// 4. Publish only after the provider confirms the payment.
await workbase.jobs.publish(jobId);

A draft can still be edited; a published job cannot. Budget fields (rewardMinor, currency, assignmentCapacity, templateVersionId) are fixed at creation because escrow already reflects them.

await workbase.jobs.update(jobId, {
  title: "Verify 500 ID photos (batch B-17)",
  deadline: "2026-11-01T12:00:00.000Z",
  tags: ["kyc", "priority"],
}); // only while status === "draft"

Worker: claim, work, submit

const { assignmentId, expiresAt } = await workbase.jobs.claim(jobId);
await workbase.assignments.start(assignmentId);

const view = await workbase.assignments.get(assignmentId);
// view.workspace is the pinned template: schema, conditions, stimuli and layout.
// view.assignment.values is the saved draft.

await workbase.assignments.saveDraft(assignmentId, {
  identity_verdict: "match",
  confidence: 0.94,
  rationale: "Same face shape, scar and ear position.",
});

await workbase.assignments.submit(assignmentId, {
  values: {
    identity_verdict: "match",
    confidence: 0.94,
    rationale: "Same face shape, scar and ear position.",
  },
  secondsSpent: 420,
});

Draft saves are deliberately relaxed so partial work autosaves. submit is the authoritative validation and returns per-field issues.

Reviewer: decide and settle

const queue = await workbase.client.reviewQueue({ status: "under_review" });

const { review } = await workbase.reviews.get(assignmentId);
await workbase.reviews.decide(assignmentId, {
  decision: "approved", // approved | rejected | revision_requested
  criteria: [{ key: "correct_verdict", value: 1 }],
  qualityScore: review.mechanicalScore ?? undefined,
});

Approval settles the reward into the worker's earnings and the platform's fees. A blocking criterion scored below 50 forces rejection regardless of the decision you send.

Receiving events

Register an endpoint once, store the secret immediately, and verify the raw body before JSON parsing. A complete receiver is in Webhook receivers.


How the money works

Most integration bugs come from treating Workbase like "post a task, get money". It does not. Four rules explain the whole model:

  1. A job cannot be published until its escrow is funded. Publishing requires fundedMinor >= reward × assignmentCapacity. A PaymentIntent in requires_payment funds nothing — money counts when the provider confirms.
  2. Balances are derived, never stored. Every figure comes from an append-only double-entry journal. There is no mutable balance anywhere, so a balance can always be re-derived and audited.
  3. Approval settles the reward. Approving a submission debits client:funds_held, credits the worker's earnings and the platform's fees.
  4. Prepaid wallet balance is not earnings. A wallet is spendable cash that can only move into job escrow — it cannot be withdrawn. Earned money leaves via the payout flow. Keeping these separate is what stops a client from paying a worker out of someone else's escrow.

Amounts are always integer minor units (amountMinor), never floats.


Client configuration

const workbase = new WorkbaseClient({
  apiKey: process.env.WORKBASE_API_KEY!,
  baseUrl: "https://acme.workbase.example/api",
  maxRetries: 2,        // transient failures, safe/idempotent requests only
  timeoutMs: 30_000,    // per attempt
  headers: { "x-request-source": "billing-sync" },
  fetch: customFetch,   // inject for tests, proxies or agents
});

Escape hatch for anything not modelled:

await workbase.request({ method: "GET", path: "/jobs", query: { page: 2 } });

Resources

organizations

Read and update settings for the organization associated with the API key:

const organization = await workbase.organizations.current();
await workbase.organizations.update({
  name: "Acme Verification Ltd",
  contactEmail: "[email protected]",
});

Organization changes require the corresponding org:read or org:update API key permission.

jobs

const feed = await workbase.jobs.list({ pageSize: 20, difficulty: "easy" });

const { jobId } = await workbase.jobs.create({
  title: "Transcribe interview audio",
  description: "Transcribe the attached 10-minute clip.",
  templateVersionId,
  rewardMinor: 2_500,
  currency: "USD",
  assignmentCapacity: 1,
  reviewMode: "manual",
});

await workbase.jobs.update(jobId, { title: "Transcribe interview audio (v2)" }); // draft only
await workbase.jobs.publish(jobId);   // fails INSUFFICIENT_FUNDS until escrow is funded
await workbase.jobs.claim(jobId);     // worker side: reserve a slot
await workbase.jobs.get(jobId);

taskContent may be a plain record keyed by stimulus id or the API's row form; the SDK converts the record to rows for you. Blank keys are dropped.

taskContent: { reference_photo: fileId, expected_text: "ACME-4471" }
// sent as [{ key: "reference_photo", value: fileId }, …]

assignments

const workspace = await workbase.assignments.get(assignmentId); // definition + material
await workbase.assignments.start(assignmentId);
await workbase.assignments.saveDraft(assignmentId, values);     // autosave, draft rules relaxed
await workbase.assignments.submit(assignmentId, {
  values,
  secondsSpent: 420,
});

Draft saves skip required-field checks so partial work is allowed; submit runs the full authoritative validation and returns per-field issues.

reviews

await workbase.reviews.decide(assignmentId, {
  decision: "approved",                       // approved | rejected | revision_requested
  criteria: [{ key: "correct_verdict", value: 1 }],
  qualityScore: 92,
});

A blocking criterion scored below 50 forces a rejection, whatever decision you send.

wallet

const wallet = await workbase.wallet.get();
// wallet.balances      → ledger balance (authoritative)
// wallet.provider      → provider mirror, for comparison only
// wallet.transactions  → every movement

const topup = await workbase.wallet.topup({
  amountMinor: 20_000,
  currency: "USD",
  idempotencyKey: `topup-${Date.now().toString(36)}`,
});
// Redirect to topup.checkoutUrl. Balance is credited only by a verified
// provider event, so abandoning the checkout credits nothing.

await workbase.wallet.spendToJob({ jobId, amountMinor: 20_000 }); // straight into escrow

A top-up requires a verified email address: if the provider reverses the payment, an unverified address could not be repaid.

payments

const intent = await workbase.payments.fund({
  purpose: "job_funding",   // job_funding | program_funding | account_credit
  jobId,
  amountMinor: 25_000,
  currency: "USD",
  idempotencyKey: `fund-${jobId}`,
});

payouts

const open = await workbase.payouts.list({ pageSize: 20 });

await workbase.payouts.create({
  amountMinor: 4_500,
  currency: "USD",
  idempotencyKey: `withdraw-${assignmentId}`,
});

// Finance side (requires payout:review):
await workbase.payouts.decide(payoutId, { decision: "approve" });
await workbase.payouts.decide(payoutId, { decision: "reject", reason: "Bank details rejected" });

Approval enqueues the provider transfer; rejection returns the reserved funds to the worker's earnings.

client

Employer-side reads. billing() is the one call that backs a whole escrow screen:

const escrow = await workbase.client.billing();
escrow.balance;          // escrow held
escrow.jobs[0].shortfallMinor;  // what this job still needs before publishing
escrow.jobs[0].canPublish;      // whether publishing will be accepted
escrow.instantPayOnApproval;    // org policy: pay workers the moment work is approved

await workbase.client.jobs();
await workbase.client.reviewQueue({ status: "under_review" });
await workbase.client.submission(assignmentId);
await workbase.client.createOrganization({ name: "Acme Verification Ltd" });

files

const file = await workbase.files.upload({
  data: buffer,
  filename: "reference-photo.jpg",
  contentType: "image/jpeg",
  purpose: "job_attachment",
  jobId,
});
// file.fileId, file.scanStatus, file.downloadUrl (a signed, short-lived URL)

const { body, filename } = await workbase.files.download(file.fileId, signedToken);
// …or just fetch file.downloadUrl directly until it expires.

Uploads are scanned and quarantined if infected. Declared image types are checked against their bytes, so a renamed .jpg is rejected.

templates

const { items } = await workbase.templates.list();

await workbase.templates.create({
  definition: {
    schemaVersion: 1,
    key: "video_photo_identity_match",
    name: "Video-to-Photo Identity Match",
    category: "vision",
    summary: "Confirm the person in a capture matches a reference photo.",
    sections: [
      {
        id: "verdict",
        title: "Match verdict",
        fields: [
          {
            id: "identity_verdict",
            type: "match_verification",
            label: "Does the capture show the same person as the reference photo?",
            required: true,
            matchKind: "identity_match",
            referenceKeys: ["reference_photo", "live_capture"],
            verdictOptions: [
              { value: "match", label: "Match — same person", score: 100 },
              { value: "cannot_tell", label: "Cannot tell", score: 40 },
            ],
          },
        ],
      },
    ],
  },
});

This is how a verification company defines its own task types. The definition is validated server-side and the owning organization is stamped by the server.

cases

const created = await workbase.cases.create({
  subject: "Payout not received",
  category: "payouts",
  message: "Approved three days ago and nothing has arrived.",
});
// created.reference === "CASE-1042" — quote this in support.

const thread = await workbase.cases.get(created.id);
await workbase.cases.reply(created.id, { body: "Any update on this?" });

A case is a thread; staff replies arrive by email as well as in-app. Internal notes are filtered server-side and are never returned to a requester.

me

await workbase.me.assignments();
await workbase.me.earnings();
await workbase.me.wallet();

const { items, unreadCount } = await workbase.me.notifications({ unreadOnly: true });
await workbase.me.markNotificationsRead(["<id>"]);  // or "all"

await workbase.me.notificationPreferences();
await workbase.me.updateNotificationPreferences({ emailDigest: "immediate" });

integrations

const { connections, availableKinds } = await workbase.integrations.list();

const integration = await workbase.integrations.create({
  kind: "n8n", // generic_rest | n8n | make | zapier | openpay | custom
  name: "n8n bridge",
  config: { baseUrl: "https://n8n.example.com/webhook/workbase" },
  secret: process.env.N8N_TOKEN, // encrypted at rest, never returned
  scopes: ["jobs:write"],
  mappedTemplateKey: "transcription_task",
});

await workbase.integrations.get(integration.id);
await workbase.integrations.update(integration.id, { status: "paused" });
await workbase.integrations.remove(integration.id); // destroys the credential

webhooks

const { endpoints, availableEvents } = await workbase.webhooks.list();

const created = await workbase.webhooks.create({
  name: "Acme order service",
  url: "https://yourapp.example/hooks/workbase",
  events: ["payout.paid", "assignment.submitted"],
});
// created.id, and created.secret returned EXACTLY ONCE — store it now.

await workbase.webhooks.update(created.id, { events: ["payout.paid"] });
await workbase.webhooks.rotate(created.id);  // previous secret revoked immediately
await workbase.webhooks.test(created.id);
await workbase.webhooks.remove(created.id);

apiKeys

const { token } = await workbase.apiKeys.create({
  name: "Billing sync",
  scopes: ["job:read", "payment:fund"],
});
// `token` is returned exactly once.

await workbase.apiKeys.list();
await workbase.apiKeys.rotate(keyId);   // previous key stops working immediately
await workbase.apiKeys.revoke(keyId);

Scope a key to what it needs. A key that can move money should not also be the key that reads the marketplace.


Verification tasks

Verification is a first-class template type, not a generic form.

  • match_verification fields take one verdict over several stimuli, a calibrated confidence (0–1), and a written rationale. A verdict with neither confidence nor reason is rejected at submit — an unverifiable answer is worth nothing to a client.
  • Always offer an escape verdict (cannot_tell, illegible, inconclusive). Forcing a binary answer on an unreadable capture is how a client buys bad data.
  • Stimuli are reachable by the worker. The job's media resolves to signed URLs server-side under the worker's own access check, and renders above the answer form. Attach them with stimulus ids matching referenceKeys:
await workbase.jobs.create({
  /* ... */
  taskContent: {
    // stimulus id -> file id or url
    live_capture: "<fileId of the video>",
  },
});
  • Auto-approval requires confidence. verification_confident fails below 0.6, so a well-formed coin flip escalates to a human rather than settling itself.
  • Review outcomes record skill: templateKey, so a worker's identity-match record is separate from their data-entry record in reputation.

Built-ins: video_photo_identity_match, document_text_match, image_pair_similarity_match, document_authenticity_check.


Idempotency

Any request that moves money must carry an idempotencyKey. It is a stable per logical operation, not per attempt.

await workbase.payments.fund({ /* ... */ idempotencyKey: `fund-${jobId}` });
await workbase.payouts.create({ /* ... */ idempotencyKey: `withdraw-${assignmentId}` });

Reusing a key returns the original result instead of performing the action twice, which is what makes an at-least-once retry safe. Because these requests are idempotent, the client retries them automatically on transient failures — a non-idempotent POST is attempted exactly once.


Errors and retries

Every non-2xx response throws WorkbaseError:

import { WorkbaseError, WorkbaseConnectionError } from "@argxi/workbase";

try {
  await workbase.jobs.publish(jobId);
} catch (error) {
  if (error instanceof WorkbaseError) {
    error.status;         // 400, 401, 403, 409, 422, 429, 5xx
    error.code;           // INSUFFICIENT_FUNDS, NOT_FOUND, VALIDATION_FAILED, …
    error.message;        // safe to show a user
    error.correlationId;  // quote this in a support case
    error.details;        // structured extras, when the API supplies them
  } else if (error instanceof WorkbaseConnectionError) {
    // network failure or timeout — safe to retry if idempotent
  }
}

| Code | Meaning | Typical response | | --- | --- | --- | | INSUFFICIENT_FUNDS | Escrow does not cover the job | Fund it — do not retry | | VALIDATION_FAILED | Body failed validation | details.issues names each field | | FORBIDDEN | Key lacks the scope, or the resource is another tenant's | Fix the scope | | CONFLICT | Illegal state transition (e.g. publishing a funded job twice) | Re-read state | | RATE_LIMITED | Too many requests | Back off; Retry-After is honoured | | NOT_CONFIGURED | A dependency is unset (e.g. no SMTP, no provider wallet) | Operator action |

429 and 5xx are retried with exponential backoff for safe methods and idempotent requests, honouring Retry-After. Nothing else is retried: retrying a 400 is how a duplicate payout happens.


Rate limits

Per key and per IP, per endpoint family: reads ~300/min, writes ~60/min, payouts 5/hour, submissions 60/min, uploads 60/5min. A RATE_LIMITED response carries Retry-After; the client already waits, so a single-threaded integration rarely sees one.


Pagination

List endpoints accept page and pageSize and return:

{ items, page, pageSize, total, hasMore }

Iterate total/hasMore, not items.length — a short page does not mean the end.


Webhook receivers

Verify the raw body before parsing. Verifying a re-serialized body breaks the signature.

import { verifyWebhookSignature } from "@argxi/workbase";

export async function POST(request: Request) {
  const raw = await request.text(); // exactly the bytes that were signed
  const ok = await verifyWebhookSignature({
    payload: raw,
    signatureHeader: request.headers.get("x-workbase-signature") ?? "",
    secret: process.env.WORKBASE_WEBHOOK_SECRET!,
  });
  if (!ok) return new Response("bad signature", { status: 400 });

  const event = JSON.parse(raw);
  await handle(event); // must be idempotent: deliveries are at-least-once
  return new Response("ok", { status: 200 });
}

signWebhookPayload(secret, payload, timestamp) is exported so you can test a receiver against the same contract. Use X-Workbase-Event-ID as your dedupe key (exported as WEBHOOK_EVENT_ID_HEADER) — a redelivery is the same event, not a new one. X-Workbase-Delivery identifies one delivery attempt chain.


Sandbox testing

The sandbox provider settles nothing on its own and emits no webhooks, so the SDK exposes the confirmation call. Together, the whole money flow runs offline:

const intent = await workbase.payments.fund({ /* ... */ idempotencyKey: "t-1" });
// intent.reference is the checkout reference the provider echoes back.
await workbase.wallet.completeSandboxFunding(intent.reference); // sandbox only

await workbase.jobs.publish(jobId);          // now funded
// claim → submit → approve → instant payout, all without a provider

It refuses any non-sandbox intent, so it cannot fake a real payment.


What the SDK deliberately does not cover

Some endpoints exist in the API but are not for API keys. This is intentional, and enforced by a parity test so the boundary cannot quietly rot:

| Surface | Why | | --- | --- | | /auth/* | Session cookies; the SDK is server-to-server | | /me/profile | Personal profile requires a human browser session | | /setup/* | One-time platform bootstrap | | /admin/* | Platform-staff console, human-facing | | /payments/webhooks/*, /webhooks/inbound | Inbound provider callbacks |

Everything else public is covered. tests/unit/sdk-parity.test.ts walks the real route tree and fails if a new endpoint ships without an SDK method — that is how the previous drift (wallets, cases, employer surface, integrations, payout decisions) was found in the first place.


Links

License

Proprietary. All rights reserved.