The @tally/sdk package is a fully typed TypeScript HTTP client for the Tally API. It uses undici under the hood and supports all Tally v1 endpoints — subject snapshots, update proposals, tenant management, refresh requests, and webhook subscriptions — with zero runtime dependencies beyond undici itself. Every method returns a typed Promise so your editor can autocomplete parameters and response shapes.
Installation
Install the package with your preferred Node.js package manager:
@tally/sdk requires Node.js ≥ 20. It ships as an ES module ("type": "module") and exports TypeScript declaration files from dist/index.d.ts.
Creating a client
Call createClient once at application startup and reuse the returned client throughout your code. You can embed your JWT token in the client options or leave it out and handle auth at the call site.
import { createClient } from '@tally/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
token: 'your-jwt-token', // optional; can also set per-call
});
createClient accepts a CreateClientOptions object:
| Option | Type | Required | Description |
|---|
baseUrl | string | ✅ | Base URL for all API requests, e.g. https://api.example.com |
token | string | — | Bearer token sent in the Authorization header on every request |
The factory returns a TallyClient with namespaced sub-clients: subjects, updates, entityStates, tenants, refreshRequests, and webhooks.
Two top-level methods are available for service introspection.
// Unauthenticated liveness probe — GET /health
const { ok, service } = await client.health();
// Service metadata — GET /v1/meta
const meta = await client.meta();
console.log(meta.version, meta.gitSha, meta.env);
Return types:
health() → Promise<HealthResponse> — { ok: true; service: string }
meta() → Promise<MetaResponse> — includes service, ts, version, node, env, gitSha, and builtAt
client.health() does not require an auth token and is safe to call as a startup check.
Subjects (client.subjects)
The subjects namespace covers all read paths for identity snapshots: fetching the current state, retrieving individual snapshot versions, paginating history, exporting the full chain, and diffing between versions. All methods require an active grant or subject ownership.
getCurrent(subjectType, subjectId, opts?)
Returns the rich current-state view for a subject: identity data, provenance summary, and optional cryptographic verification.
const current = await client.subjects.getCurrent(
'entity',
'ent_acme_001',
{ tenantId: 'my-org', verify: 'hash' }
);
console.log(current.identity);
console.log(current.current_snapshot.snapshot_version);
| Parameter | Type | Description |
|---|
subjectType | SubjectType | 'entity' or 'individual' |
subjectId | string | Subject identifier |
opts.tenantId | string | Tenant context for permission resolution |
opts.verify | VerifyMode | 'none' | 'hash' | 'chain' |
opts.depth | number | Chain depth for 'chain' verify mode |
Returns: Promise<SubjectCurrentResponse> — Scope required: read_latest (or owner).
getLatestSnapshot(subjectType, subjectId, opts?)
Fetches the latest snapshot for a subject as either a header or full envelope.
const snapshot = await client.subjects.getLatestSnapshot(
'entity',
'ent_acme_001',
{ tenantId: 'my-org', view: 'full' }
);
| Parameter | Type | Description |
|---|
subjectType | SubjectType | 'entity' or 'individual' |
subjectId | string | Subject identifier |
opts.view | ViewMode | 'header' (default) or 'full' to include the envelope |
opts.verify | VerifyMode | Hash verification mode |
opts.tenantId | string | Tenant context |
Returns: Promise<SnapshotHeader | SnapshotFull> — Scope required: read_latest.
getSnapshotByVersion(subjectType, subjectId, version, opts?)
Fetches the snapshot at a specific version number.
const v3 = await client.subjects.getSnapshotByVersion(
'entity',
'ent_acme_001',
3,
{ tenantId: 'my-org', view: 'full' }
);
| Parameter | Type | Description |
|---|
subjectType | SubjectType | 'entity' or 'individual' |
subjectId | string | Subject identifier |
version | number | Snapshot version number |
opts.view | ViewMode | 'header' or 'full' |
opts.tenantId | string | Tenant context |
Returns: Promise<SnapshotHeader | SnapshotFull> — Scope required: read_lineage.
getSnapshotById(snapshotId, opts?)
Fetches a single snapshot by its UUID, regardless of subject.
const snap = await client.subjects.getSnapshotById(
'snap_01j9xk...',
{ tenantId: 'my-org', view: 'full' }
);
Returns: Promise<SnapshotHeader | SnapshotFull> — Scope required: read_snapshot_by_id.
getSnapshotProof(snapshotId, opts?)
Returns the hash and chain-proof record for a snapshot — useful for manual integrity verification.
const proof = await client.subjects.getSnapshotProof('snap_01j9xk...', {
tenantId: 'my-org',
});
console.log(proof.envelope_hash, proof.prev_hash);
Returns: Promise<SnapshotProof> — Scope required: read_snapshot_by_id.
listSnapshots(subjectType, subjectId, opts?)
Returns a paginated snapshot history for a subject.
const history = await client.subjects.listSnapshots(
'entity',
'ent_acme_001',
{ tenantId: 'my-org', limit: 20, order: 'desc', view: 'full' }
);
for (const snap of history.items) {
console.log(snap.snapshot_version, snap.generated_at);
}
// Follow the cursor for the next page
if (history.page.next_cursor) {
const next = await client.subjects.listSnapshots(
'entity', 'ent_acme_001',
{ tenantId: 'my-org', cursor: history.page.next_cursor }
);
}
| Parameter | Type | Description |
|---|
opts.limit | number | Max items per page |
opts.order | SortOrder | 'asc' or 'desc' |
opts.cursor | string | Pagination cursor from a previous response |
opts.view | ViewMode | 'header' or 'full' |
Returns: Promise<ListSnapshotsResponse> — Scope required: read_lineage.
listChainProof(subjectType, subjectId, opts?)
Returns a paginated list of hash-only chain proof items for a subject — lighter than full snapshot history.
const chain = await client.subjects.listChainProof(
'entity',
'ent_acme_001',
{ tenantId: 'my-org', order: 'asc' }
);
Returns: Promise<ListChainProofResponse> — Scope required: read_lineage.
export(subjectType, subjectId, opts?)
Exports the complete snapshot chain with all envelopes and hashes for a subject. Use this for offline verification with the CLI.
const exported = await client.subjects.export('entity', 'ent_acme_001', {
tenantId: 'my-org',
});
// exported.snapshots contains every version with envelope + hash fields
Returns: Promise<ExportSnapshotsResponse> — Scope required: read_lineage.
diffByVersion(subjectType, subjectId, fromVersion, toVersion, opts?)
Returns an RFC 6902 JSON Patch diff between two snapshot version numbers.
const diff = await client.subjects.diffByVersion(
'entity',
'ent_acme_001',
2,
4,
{ tenantId: 'my-org', includeAttribution: 'changed_only' }
);
for (const op of diff.ops) {
console.log(op.op, op.path, op.value);
}
| Parameter | Type | Description |
|---|
fromVersion | number | Starting snapshot version |
toVersion | number | Ending snapshot version |
opts.format | 'rfc6902' | Patch format (currently only rfc6902) |
opts.include | string | Dot-notation path filter |
opts.includeAttribution | 'none' | 'changed_only' | Attribution detail level |
opts.verify | VerifyMode | Hash verification mode |
Returns: Promise<DiffResponse> — Scope required: read_diff.
diffById(fromSnapshotId, toSnapshotId, opts?)
Same as diffByVersion but addresses snapshots by UUID instead of version number.
const diff = await client.subjects.diffById(
'snap_01j9xk_from',
'snap_01j9xk_to',
{ tenantId: 'my-org' }
);
Returns: Promise<DiffResponse> — Scope required: read_diff.
Updates (client.updates)
The updates namespace handles the two-phase write path: you propose a patch against the current snapshot, then apply it to produce a new snapshot version. Proposing requires the proposer role; applying requires the editor role.
// Step 1 — Propose an RFC 6902 patch
const { update_id } = await client.updates.propose(
{
subject_id: 'ent_acme_001',
base_snapshot_id: 'snap_01j9xk...',
base_snapshot_version: 4,
patch: [
{ op: 'replace', path: '/attributes/entity_status', value: 'inactive' },
],
// optional idempotency key:
request_id: 'req_my_unique_key',
created_by: 'principal_abc',
},
{ tenantId: 'my-org' }
);
// Step 2 — Apply the proposed update (produces a new snapshot version)
const result = await client.updates.apply(update_id, { tenantId: 'my-org' });
| Field | Type | Description |
|---|
subject_id | string | Target subject identifier |
base_snapshot_id | string | UUID of the snapshot being patched |
base_snapshot_version | number | Version of the snapshot being patched |
patch | PatchOp[] | RFC 6902 operations (add, remove, replace) |
request_id | string | Optional idempotency key |
created_by | string | Optional principal attribution |
propose → Promise<ProposeUpdateResponse> ({ update_id: string })
apply → Promise<Record<string, unknown>>
Always pass the base_snapshot_id and base_snapshot_version together. The API uses both fields to detect concurrent modifications and will reject a proposal if another update has already been applied since your read.
Entity States (client.entityStates)
Use entityStates.create to write the first-ever snapshot for a subject. This is the initial write that bootstraps the chain. Subsequent writes go through the updates namespace.
create(envelope, opts?)
const result = await client.entityStates.create(
{
subject_type: 'entity',
subject_id: 'ent_acme_001',
attributes: {
entity_status: 'active',
legal_name: 'Acme Corporation',
registration_country: 'US',
},
},
{ tenantId: 'my-org' }
);
| Parameter | Type | Description |
|---|
envelope | Record<string, unknown> | Entity-state envelope conforming to the Tally schema |
opts.tenantId | string | Tenant context |
Returns: Promise<Record<string, unknown>> — Role required: editor (owner required in production).
Tenants (client.tenants)
The tenants namespace covers the full lifecycle of tenant management: creating tenants, managing memberships, declaring subject ownership, granting counterparty access, and listing accessible subjects.
Creates a new tenant. The calling principal becomes tenant_admin automatically.
const tenant = await client.tenants.create({
tenant_id: 'my-org',
tenant_type: 'subject_org',
name: 'My Organisation',
slug: 'my-org', // optional
});
Returns: Promise<TenantRow>
getUsage(tenantId, opts?)
Returns resource usage metrics for a tenant over a time window.
const usage = await client.tenants.getUsage('my-org', {
from: '2024-01-01T00:00:00Z',
to: '2024-02-01T00:00:00Z',
});
Returns: Promise<TenantUsageResponse> — Role required: tenant_admin.
upsertMember(tenantId, principalId, role)
Adds a new member or updates an existing member’s role within a tenant.
await client.tenants.upsertMember('my-org', 'principal_xyz', 'editor');
Valid roles: 'tenant_admin' | 'editor' | 'proposer' | 'reader'
Returns: Promise<MembershipRow> — Role required: tenant_admin.
Declares ownership of a subject so the tenant can manage grants and updates for it.
await client.tenants.addOwner('my-org', 'entity', 'ent_acme_001', {
owner_type: 'tenant',
owner_id: 'my-org',
});
Returns: Promise<OwnerRow> — Role required: tenant_admin.
listOwners(tenantId, subjectType, subjectId)
Lists all active ownership records for a subject.
const { items } = await client.tenants.listOwners('my-org', 'entity', 'ent_acme_001');
Returns: Promise<{ items: OwnerRow[] }> — Role required: reader.
Grants a counterparty tenant one or more access scopes for a subject.
const grant = await client.tenants.createGrant('my-org', {
subject_type: 'entity',
subject_id: 'ent_acme_001',
grantee_tenant_id: 'counterparty-org',
scopes: ['read_latest', 'read_diff'],
expires_at: '2025-12-31T23:59:59Z', // optional
});
Valid scopes: 'read_latest' | 'read_lineage' | 'read_snapshot_by_id' | 'read_diff'
Returns: Promise<GrantRow> — Role required: tenant_admin + owner.
listGrants(tenantId, subjectType, subjectId)
Lists all active grants for a subject.
const { items } = await client.tenants.listGrants('my-org', 'entity', 'ent_acme_001');
Returns: Promise<{ items: GrantRow[] }> — Role required: tenant_admin + owner.
revokeGrant(tenantId, grantId)
Revokes an active grant immediately.
await client.tenants.revokeGrant('my-org', grant.grant_id);
Returns: Promise<GrantRow> — Role required: tenant_admin + owner.
listAccessibleSubjects(tenantId, opts?)
Lists all subjects this tenant can access via active grants, together with their latest snapshot summary.
const { items, page } = await client.tenants.listAccessibleSubjects('counterparty-org', {
limit: 50,
});
for (const subject of items) {
console.log(subject.subject_id, subject.scopes, subject.latest_snapshot?.snapshot_version);
}
Returns: Promise<AccessibleSubjectsResponse> — Role required: reader.
Refresh Requests (client.refreshRequests)
Refresh requests let a counterparty ask a subject owner to publish an updated snapshot. The owner then fulfills the request by linking the newly created snapshot.
Full create → fulfill flow
// Counterparty creates the request
const { refresh_request } = await client.refreshRequests.create(
'entity',
'ent_acme_001',
{
requesting_tenant_id: 'counterparty-org',
reason_code: 'periodic_review',
message: 'Annual KYC refresh required.',
requested_paths: ['/attributes/entity_status', '/attributes/legal_name'],
expires_at: '2025-03-01T00:00:00Z',
}
);
const requestId = refresh_request.refresh_request_id;
// Owner fetches the request
const { refresh_request: fetched } = await client.refreshRequests.get(
'entity',
'ent_acme_001',
requestId
);
// Owner lists all pending requests for a subject
const { items } = await client.refreshRequests.list('entity', 'ent_acme_001');
// After publishing a new snapshot, the owner marks it fulfilled
const { refresh_request: resolved } = await client.refreshRequests.fulfill(
'entity',
'ent_acme_001',
requestId,
'snap_01jnew...' // resolved_snapshot_id
);
console.log(resolved.status); // 'fulfilled'
| Method | HTTP | Description |
|---|
create(subjectType, subjectId, input) | POST /v1/subjects/:type/:id/refresh-requests | Open a new refresh request |
get(subjectType, subjectId, refreshRequestId) | GET .../refresh-requests/:id | Fetch a specific request |
list(subjectType, subjectId, opts?) | GET .../refresh-requests | Paginated list; filter by requesting_tenant_id |
fulfill(subjectType, subjectId, refreshRequestId, resolvedSnapshotId) | POST .../refresh-requests/:id/fulfill | Link a resolved snapshot to close the request |
Webhooks (client.webhooks)
Webhook subscriptions push real-time event notifications to your endpoint when identity snapshots or refresh requests change state.
const { webhook_subscription, secret } = await client.webhooks.create({
tenant_id: 'my-org',
target_url: 'https://my-app.example.com/webhooks/tally',
event_types: ['identity.snapshot.created', 'refresh_request.fulfilled'],
});
// ⚠️ Store `secret` immediately — it is returned only once and cannot be retrieved again.
// Use it to verify the HMAC signature on incoming webhook payloads.
console.log('Webhook ID:', webhook_subscription.subscription_id);
console.log('Secret:', secret);
The secret field is returned only on creation and secret rotation. Store it securely in your secrets manager straight away — you cannot retrieve it via the API afterwards.
Other webhook methods
// List all subscriptions for a tenant
const { items } = await client.webhooks.list('my-org');
// Fetch a single subscription
const { webhook_subscription } = await client.webhooks.get(subscription_id);
// Update URL, status, or event types
const updated = await client.webhooks.update(subscription_id, {
status: 'disabled',
target_url: 'https://my-app.example.com/webhooks/tally-v2',
event_types: ['identity.snapshot.created'],
});
// Rotate the HMAC secret (returns a new one-time secret)
const { secret: newSecret } = await client.webhooks.rotateSecret(subscription_id);
Valid event types: 'identity.snapshot.created' | 'refresh_request.created' | 'refresh_request.fulfilled'
| Method | HTTP | Description |
|---|
create(input) | POST /v1/webhook-subscriptions | Create a subscription; returns one-time secret |
list(tenantId) | GET /v1/webhook-subscriptions?tenant_id=... | List subscriptions for a tenant |
get(subscriptionId) | GET /v1/webhook-subscriptions/:id | Fetch a single subscription |
update(subscriptionId, input) | PATCH /v1/webhook-subscriptions/:id | Update status, URL, or event types |
rotateSecret(subscriptionId) | POST /v1/webhook-subscriptions/:id/rotate-secret | Rotate the HMAC secret |
Error handling
All SDK methods throw a plain Error when the server returns a non-2xx response. The error message includes the HTTP status code, status text, and up to 300 characters of the response body.
import { createClient } from '@tally/sdk';
const client = createClient({ baseUrl: 'https://api.example.com', token: 'my-token' });
try {
const snapshot = await client.subjects.getLatestSnapshot('entity', 'ent_acme_001', {
tenantId: 'my-org',
});
console.log(snapshot);
} catch (err) {
if (err instanceof Error) {
// Example: "HTTP 403 Forbidden: {"error":"insufficient_scope"}"
console.error('Tally API error:', err.message);
// Parse the status code if you need to branch on it
const match = err.message.match(/^HTTP (\d+)/);
const statusCode = match ? parseInt(match[1], 10) : null;
if (statusCode === 401) {
// Token expired or missing — re-authenticate
} else if (statusCode === 403) {
// Insufficient scope or role
} else if (statusCode === 404) {
// Subject or resource does not exist
}
}
}
HTTP 204 No Content responses return undefined rather than throwing. All other successful responses (2xx) are parsed as JSON and returned as the typed response object.
TypeScript types
All types are exported from @tally/sdk and fully documented in the declaration files. The key types you will use day-to-day are:
| Type | Description |
|---|
SubjectType | 'entity' | 'individual' |
GrantScope | 'read_latest' | 'read_lineage' | 'read_snapshot_by_id' | 'read_diff' |
MemberRole | 'tenant_admin' | 'editor' | 'proposer' | 'reader' |
WebhookEventType | 'identity.snapshot.created' | 'refresh_request.created' | 'refresh_request.fulfilled' |
SnapshotFull | Full snapshot including the envelope field |
SnapshotHeader | Lightweight snapshot metadata without the envelope |
DiffResponse | RFC 6902 diff result including ops, ops_hash, and optional attribution |
TenantRow | Tenant record returned by tenants.create |
GrantRow | Grant record with scopes, expiry, and revocation fields |
RefreshRequest | Full refresh request object with status, paths, and resolution details |
WebhookSubscription | Webhook subscription record (excludes the secret) |
import type {
SubjectType,
GrantScope,
MemberRole,
WebhookEventType,
SnapshotFull,
SnapshotHeader,
DiffResponse,
TenantRow,
GrantRow,
RefreshRequest,
WebhookSubscription,
} from '@tally/sdk';