# @setla/sdk

TypeScript client for the Setla API. A buyer connection pays for bounded work from a seller connection, and the payment is released or refunded after a deterministic check.

- No runtime dependencies. Uses the global `fetch`, so it runs on Node 18+, browsers, Deno, Bun and edge runtimes.
- ESM only, strict types. Unknown response fields are kept, so additive server changes don't break it.
- Sends an `Idempotency-Key` on every mutation and reuses it across automatic retries.
- Retries network errors, 429 and 5xx with exponential backoff and jitter.
- Throws typed errors.
- Includes helpers for pagination, polling, webhook signature verification and Ed25519 receipt verification.

```sh
# From an authorized checkout of the Setla source repository:
npm ci
npm --prefix sdks/typescript run build
# In your application:
npm install /absolute/path/to/setla/sdks/typescript
```

The SDK source is included in the Setla repository. It is not currently published as a public npm or PyPI package. Contact support@setla.ai for source access if needed.

## Quick start

Check `GET /v1/capabilities` for the deployment’s `payment_mode`; only `live` moves real money. The examples use `https://api.setla.ai`. Use a separate sandbox deployment for simulation. First read capabilities, complete payment setup and hosted seller verification, and obtain human approval for buyer funding limits and seller allowlisting. Self-registration works only when `registration=self_service`; otherwise join an existing workspace through its connection invitation. Use each business’s actual country instead of the sample `US`. These examples do not grant spending authority or complete hosted setup automatically.

### Seller connection

```ts
import { Setla } from '@setla/sdk';

const baseUrl = 'https://api.setla.ai';

// 1. Register once. The token is shown exactly once: store it in your secret manager now.
const { client: seller, registration } = await Setla.register({ baseUrl }, {
    name: 'Sorter Bot',
    business_name: 'Sorter Co',
});
await saveSecret('SETLA_TOKEN', registration.token.plain_text_token);
console.log('Give buyers this id:', registration.agent.id); // agt_...

// 2. Become payable. The simulated provider is ready at once; Stripe returns hosted links.
const setup = await seller.paymentSetup.start({ role: 'seller', country: 'US' });
for (const action of setup.required_actions) {
    console.log('Complete provider onboarding:', action.url);
}

// 3. Optional: an invite link lets buyers add you to their allowlist.
const invite = await seller.invites.create({ max_uses: 5, note: 'for ACME' });
console.log('Send to buyer (shown once):', invite.url);

// 4. Work loop: claim funded jobs, deliver results.
for await (const job of seller.jobs.listAll({ role: 'seller' })) {
    if (job.state !== 'open' || !['funded', 'authorized'].includes(job.payment_state)) continue;
    await seller.jobs.claim(job.id);
    const { numbers } = job.contract.input as { numbers: number[] };
    const result = await seller.jobs.deliver(job.id, { artifact: { numbers: [...numbers].sort((a, b) => a - b) } });
    console.log(job.id, result.state, result.check_status); // reviewing passed
}
```

### Buyer connection

```ts
import { Setla, ValidationError } from '@setla/sdk';

const buyer = new Setla({ baseUrl: 'https://api.setla.ai', token: process.env.SETLA_TOKEN });

await buyer.paymentSetup.start({ role: 'buyer', country: 'US' });

// Allow-list the seller, either by accepting their invite...
await buyer.invites.preview(inviteUrl); // check who sent it
await buyer.invites.accept(inviteUrl);
// ...or by setting its public id directly, together with spending limits.
// Raising limits or adding a seller is a human decision outside the sandbox: the
// registration token never carries limits:raise, so this call returns 403 there
// until a person claims the business (POST /v1/connection/claim-links, hand the
// one-time url to a human) and raises the limits in the console. In the sandbox the connection already has
// can_fund within capabilities.sandbox.self_service_spending and may stay under it.
await buyer.agents.update({
    can_fund: true,
    max_job_cents: 2_000,
    daily_limit_cents: 10_000,
    allowed_seller_ids: ['agt_01j9z...'],
});

// Commission work. Persist the key with your order record so a restarted process
// replays the same request instead of paying twice.
const job = await buyer.jobs.create(
    {
        seller_agent_id: 'agt_01j9z...',
        amount_cents: 500,
        template: 'json-sort-v1',
        input: { numbers: [42, -7, 3] },
    terms: { claim_minutes: 15, delivery_minutes: 60, max_attempts: 2 }, // optional
    },
    { idempotencyKey: `order-${orderId}` },
);
console.log(job.id); // job_01j9z... (opaque public id; store it as a string)

// Wait for the seller's delivery and the deterministic check.
const reviewed = await buyer.waitForJobState(job.id, ['reviewing', 'expired', 'cancelled'], { timeoutMs: 10 * 60_000 });
if (reviewed.state === 'reviewing' && reviewed.check_status === 'passed') {
    await buyer.verifyJobReceipt(reviewed); // throws ReceiptVerificationError if the platform signature is wrong
    await buyer.jobs.accept(job.id); // release early; otherwise it auto-releases when review closes
} else if (reviewed.state === 'reviewing') {
    await buyer.jobs.dispute(job.id, { reason: 'Artifact failed verification', evidence: ['See check_evidence.'] });
}
```

## Client options

```ts
new Setla({
    baseUrl: 'https://api.setla.ai', // origin; a trailing /v1 is tolerated
    token: '...',          // connection access key (registration and capabilities don't need one)
    maxRetries: 3,         // automatic retries after the first attempt
    timeoutMs: 30_000,     // per attempt
    retryBaseDelayMs: 500, // backoff: 500, 1000, 2000 ms (max 8000), with equal jitter
    maxRetryAfterMs: 60_000, // longer Retry-After values surface a RateLimitError instead of waiting
    fetch: customFetch,    // optional fetch implementation
    defaultHeaders: {},
});
```

Every method takes an optional last argument `{ idempotencyKey, signal, timeoutMs, maxRetries, headers }`.

## Idempotency and retries

- Every `POST`, `PUT`, `PATCH` and `DELETE` sends an `Idempotency-Key`. A random UUID v4 is used unless you pass `idempotencyKey`. Automatic retries reuse the same key, so the server returns the original response instead of performing the action twice.
- These are retried: network failures, timeouts, `429` (honouring `Retry-After`) and `5xx`. All other `4xx` responses are thrown immediately.
- Registration and token issuance return their secret only once. If the server receives a replayed key, it answers `409`, and the SDK adds a hint to the error message. Always persist the secret before doing anything else.
- `error.idempotencyKey` is set on every API or connection error, so you can retry the same logical operation later.

## Errors

| Class | When |
| --- | --- |
| `ValidationError` (422) | `error.errors` holds field to messages. Also returned for spending-limit violations. |
| `AuthenticationError` (401) | Missing, expired or revoked token. |
| `PermissionDeniedError` (403) | Token scope, allowlist, policy or stopped connection. |
| `NotFoundError` (404) | Not visible to this connection; invalid or expired invite. |
| `ConflictError` (409) | State or deadline conflict, or a key reused with a different body. |
| `PayloadTooLargeError` (413), `RateLimitError` (429, `retryAfterMs`), `InternalServerError` (5xx), `BadRequestError` | |
| `APIConnectionError` / `APITimeoutError` | No HTTP response after all retries. |
| `WebhookVerificationError`, `ReceiptVerificationError`, `WaitTimeoutError` | Helper failures. |

All of these extend `SetlaError`. HTTP errors extend `APIError`, which provides `status`, `message`, `errors`, `body`, `headers`, `idempotencyKey` and `attempts`.

## Resources

Jobs and connections are identified by opaque string ids (`job_...`, `agt_...`); pass them back exactly as returned. Path segments are URL-encoded by the client.

`client.agents` manages the connection this access key belongs to. It keeps its name for compatibility with the API's `agent` fields and `agent:*` abilities; Setla does not create or host agents.

| Group | Methods |
| --- | --- |
| `client.capabilities` | `get()`, `openapi()`, `receiptPublicKeys({ refresh })` |
| `client.agents` | `register(params)`, `get()`, `update(params)`, `stop()` |
| `client.tokens` | `issue({ name, abilities, expires_in_days })`, `revoke(id)` |
| `client.paymentSetup` | `start({ role, country })`, `get()`, `getPaymentAction(jobId)` |
| `client.webhooks` | `get()`, `configure(url)`, `disable()`, `rotateSecret()`, `verify(opts)` |
| `client.jobs` | `create`, `get`, `list({ role, per_page, page })`, `listAll({ role })`, `claim`, `deliver`, `accept`, `dispute`, `cancel`, `waitForState` |
| `client.invites` | `create`, `list`, `revoke(id)`, `preview(codeOrUrl)`, `accept(codeOrUrl)` |
| `client.sandbox` | `advanceClock(jobId, seconds)`. Only works when `simulation_available` is true. |
| `client` | `waitForJobState(jobId, states, opts)`, `verifyJobReceipt(jobOrEvidence)`, `request(raw)`, `setToken(token)` |

Request bodies use the API's own snake_case field names. `waitForJobState` matches against both `state` and `payment_state`; the two vocabularies don't overlap, so you can wait for `'funded'` or `'released'` as well as `'reviewing'`.

## Webhooks

Deliveries are signed with `X-Agent-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + rawBody)>`. Verify the **raw** body:

```ts
import express from 'express';
import { verifyWebhookSignature, WebhookVerificationError } from '@setla/sdk';

app.post('/setla/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
    try {
        const event = await verifyWebhookSignature({
            payload: req.body, // Buffer/Uint8Array, exactly as received
            header: req.get('X-Agent-Signature'),
            secret: [currentSecret, previousSecret].filter(Boolean), // accept both during rotation
            toleranceSeconds: 300,
        });
        if (await alreadyProcessed(event.id)) return res.sendStatus(200); // at-least-once delivery
        await handle(event.type, event.job_id);
        res.sendStatus(200);
    } catch (error) {
        res.sendStatus(error instanceof WebhookVerificationError ? 400 : 500);
    }
});
```

The comparison is constant-time (WebCrypto `verify`). `signWebhookPayload(secret, body)` builds a valid header, which is useful for tests.

## Verification receipts

Every check produces a receipt (`job_id`, `attempt`, `template`, `checker_version`, `input_hash`, `artifact_hash`, `hash_alg`, `result`, `checked_at`) signed with Ed25519. The signature covers the receipt's canonical JSON (`canonicalJson`): keys sorted recursively by UTF-8 byte order, no whitespace, slashes and unicode left unescaped, empty objects as `[]`, and numbers in ECMAScript `JSON.stringify` form (`1.0` -> `1`, `-0` -> `0`, `1e21` -> `1e+21`). Public keys are published in `GET /v1/capabilities` under `verification.receipt_public_keys`: the `current` key plus any `previous` keys kept after a rotation. Verification picks the key by the evidence `key_id`, so receipts signed before a rotation keep verifying.

When a receipt has `hash_alg: 'sha256-canonical-json-v1'`, `input_hash` and `artifact_hash` are the SHA-256 of the canonical JSON of `contract.input` and the delivery artifact, and you can recompute them from the job resource. Older receipts without `hash_alg` are reported as `checked: false`. Integers beyond ±2^53 can't be reproduced after `JSON.parse`.

```ts
const verified = await client.verifyJobReceipt(job); // newest delivery's receipt; keys fetched and cached
const hashes = await verifyReceiptHashes(job); // throws ReceiptVerificationError on a mismatch
// recomputeReceiptHashes(job, delivery?) returns { checked, matches, inputMatches, artifactMatches, ... } without throwing
// or offline, with pinned keys:
import { verifyReceipt, verifyReceiptHashes } from '@setla/sdk';
await verifyReceipt(job.check_evidence!, pinnedKeys, { expectedJobId: job.id });
```

If the server had no usable signing key, evidence carries `signed: false` and an `unsigned_reason`, and `verifyReceipt` rejects it. The check result itself still stands.

Verification uses WebCrypto Ed25519 where available, and falls back to `node:crypto` (for example on Node 18).

## Development

```sh
npm run typecheck   # tsc for src (no Node types) and tests
npm test            # node --experimental-strip-types --test test/*.test.ts
npm run build       # tsc -p . -> dist/
BASE_URL=http://127.0.0.1:8099 node --experimental-strip-types ../e2e/smoke.mjs   # against a local server
```

`test/fixtures/receipt-fixture.json` was produced by `test/fixtures/generate-receipt-fixture.php`, which uses PHP sodium and the server's own `CanonicalJson` class. It proves byte-for-byte compatibility with the server's canonical JSON and receipt hashes, including number edge cases, unicode, slashes and empty containers.


Before funding a captured long Stripe job, show the buyer `capabilities.cancellation_policy.summary` and its `terms_url`, and obtain acknowledgment. Only then include `"cancellation_policy_acknowledgment": "actual_processing_cost_v1"` in the job creation request. Missing acknowledgment returns 422 without creating the job, reserving budget, or queuing funding. Never automatically retry with acknowledgment or assume it from a saved card or funding permission. Short authorized jobs, simulation, and prepaid jobs do not require this long-card-job acknowledgment. Bounty postings whose default job windows require upfront Stripe capture also require the buyer's acknowledgment; it is saved at posting and reused when a seller claims. Existing bounties without acknowledgment cannot silently create a captured long job if the funding policy changes.
