Skip to main content
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:
npm install @tally/sdk
@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:
OptionTypeRequiredDescription
baseUrlstringBase URL for all API requests, e.g. https://api.example.com
tokenstringBearer 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.

client.health() and client.meta()

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);
ParameterTypeDescription
subjectTypeSubjectType'entity' or 'individual'
subjectIdstringSubject identifier
opts.tenantIdstringTenant context for permission resolution
opts.verifyVerifyMode'none' | 'hash' | 'chain'
opts.depthnumberChain 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' }
);
ParameterTypeDescription
subjectTypeSubjectType'entity' or 'individual'
subjectIdstringSubject identifier
opts.viewViewMode'header' (default) or 'full' to include the envelope
opts.verifyVerifyModeHash verification mode
opts.tenantIdstringTenant 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' }
);
ParameterTypeDescription
subjectTypeSubjectType'entity' or 'individual'
subjectIdstringSubject identifier
versionnumberSnapshot version number
opts.viewViewMode'header' or 'full'
opts.tenantIdstringTenant 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 }
  );
}
ParameterTypeDescription
opts.limitnumberMax items per page
opts.orderSortOrder'asc' or 'desc'
opts.cursorstringPagination cursor from a previous response
opts.viewViewMode'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);
}
ParameterTypeDescription
fromVersionnumberStarting snapshot version
toVersionnumberEnding snapshot version
opts.format'rfc6902'Patch format (currently only rfc6902)
opts.includestringDot-notation path filter
opts.includeAttribution'none' | 'changed_only'Attribution detail level
opts.verifyVerifyModeHash 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.

propose(input, opts?) and apply(updateId, opts?)

// 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' });
FieldTypeDescription
subject_idstringTarget subject identifier
base_snapshot_idstringUUID of the snapshot being patched
base_snapshot_versionnumberVersion of the snapshot being patched
patchPatchOp[]RFC 6902 operations (add, remove, replace)
request_idstringOptional idempotency key
created_bystringOptional principal attribution
  • proposePromise<ProposeUpdateResponse> ({ update_id: string })
  • applyPromise<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' }
);
ParameterTypeDescription
envelopeRecord<string, unknown>Entity-state envelope conforming to the Tally schema
opts.tenantIdstringTenant 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.

create(input)

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.

addOwner(tenantId, subjectType, subjectId, input)

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.

createGrant(tenantId, input)

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'
MethodHTTPDescription
create(subjectType, subjectId, input)POST /v1/subjects/:type/:id/refresh-requestsOpen a new refresh request
get(subjectType, subjectId, refreshRequestId)GET .../refresh-requests/:idFetch a specific request
list(subjectType, subjectId, opts?)GET .../refresh-requestsPaginated list; filter by requesting_tenant_id
fulfill(subjectType, subjectId, refreshRequestId, resolvedSnapshotId)POST .../refresh-requests/:id/fulfillLink 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.

create(input)

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'
MethodHTTPDescription
create(input)POST /v1/webhook-subscriptionsCreate 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/:idFetch a single subscription
update(subscriptionId, input)PATCH /v1/webhook-subscriptions/:idUpdate status, URL, or event types
rotateSecret(subscriptionId)POST /v1/webhook-subscriptions/:id/rotate-secretRotate 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:
TypeDescription
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'
SnapshotFullFull snapshot including the envelope field
SnapshotHeaderLightweight snapshot metadata without the envelope
DiffResponseRFC 6902 diff result including ops, ops_hash, and optional attribution
TenantRowTenant record returned by tenants.create
GrantRowGrant record with scopes, expiry, and revocation fields
RefreshRequestFull refresh request object with status, paths, and resolution details
WebhookSubscriptionWebhook subscription record (excludes the secret)
import type {
  SubjectType,
  GrantScope,
  MemberRole,
  WebhookEventType,
  SnapshotFull,
  SnapshotHeader,
  DiffResponse,
  TenantRow,
  GrantRow,
  RefreshRequest,
  WebhookSubscription,
} from '@tally/sdk';