@argxi/workbase
v0.2.0
Published
Official TypeScript/JavaScript client SDK for the Workbase job marketplace and task-execution API.
Maintainers
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
- Quickstart
- Implementation guide
- How the money works
- Client configuration
- Resources
- jobs · assignments · reviews
- wallet · payments · payouts · client
- files · templates · cases
- me · integrations · webhooks · apiKeys
- organizations
- Verification tasks
- Idempotency
- Errors and retries
- Rate limits
- Pagination
- Webhook receivers
- Sandbox testing
- What the SDK deliberately does not cover
Install
npm install @argxi/workbaseRequires 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:
- A job cannot be published until its escrow is funded. Publishing requires
fundedMinor >= reward × assignmentCapacity. APaymentIntentinrequires_paymentfunds nothing — money counts when the provider confirms. - 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.
- Approval settles the reward. Approving a submission debits
client:funds_held, credits the worker's earnings and the platform's fees. - 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 escrowA 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 credentialwebhooks
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_verificationfields 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_confidentfails 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 providerIt 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
- Runnable examples:
examples/employer-lifecycle.ts,examples/webhook-receiver.ts - Integration guide:
docs/integrations.md - HTTP contract:
docs/openapi.yaml - Operations runbook:
docs/runbook.md
License
Proprietary. All rights reserved.
