@otskit/client
TypeScript/JavaScript client for OpenTimestamps with enterprise-grade resilience patterns

@otskit/client is the official client SDK for submitting, upgrading, and verifying OpenTimestamps proofs. It sits on top of @otskit/core — the low-level protocol engine — and wraps it in a high-level API with production-ready resilience patterns built in.
Features
Complete OpenTimestamps Workflow
stamp() — Hash your data, build a Merkle tree with a secure nonce, and submit to multiple calendar servers simultaneously
upgrade() — Query calendars for Bitcoin confirmations and merge them into the pending proof
verify() — Verify a completed proof against the Bitcoin blockchain via Esplora
Enterprise-Grade Resilience
- Circuit Breaker — Per-calendar isolation; one failing calendar never affects the others
- Exponential Backoff — Three strategies (
exponential, linear, constant) with three jitter modes (full, equal, none)
- Dual Timeouts — Independent
totalTimeoutMs (whole operation) and connectTimeoutMs (per attempt)
- Threshold Submissions —
stamp() requires N-of-M successful submissions (default 2-of-4); configurable
- Fail-Fast on 4xx — Client errors are never retried; only 5xx and network failures trigger retries
Developer Experience
- TypeScript-first — Strict types throughout; full IntelliSense for every option and error
- Node.js 20+ — Requires Node.js; uses native
crypto, dns, and net APIs not available in browsers or edge runtimes
- Tree-shakeable — Dual ESM/CJS build;
@otskit/core is the only runtime dependency, no third-party packages
AbortController support — Cancel any in-flight operation at any level
- Observable — Drop-in
Logger interface compatible with console, pino, winston, etc.
- Built-in SHA-256 helpers —
hashFile() and hashBuffer() so you don't need to wire up crypto yourself
Note on confirmation times: After stamp(), the proof is pending — registered with calendar servers but not yet anchored to Bitcoin. Confirmations typically arrive within ~60 minutes, but can take several hours during network congestion. Call upgrade() periodically to check; a pending proof is not a failed proof — an UpgradeError simply means the blockchain hasn't confirmed yet.
Installation
npm install @otskit/client
@otskit/core is a regular dependency and installs automatically; no separate install is needed.
Quick Start
import { OpenTimestampsClient, hashFile } from '@otskit/client'
import { writeFileSync } from 'fs'
const client = new OpenTimestampsClient()
const hash = await hashFile('contract.pdf')
const pendingProof = await client.stamp(hash)
writeFileSync('contract.pdf.ots', pendingProof)
console.log('Proof saved — Bitcoin confirmation usually arrives in ~60 minutes.')
const upgradedProof = await client.upgrade(pendingProof)
writeFileSync('contract.pdf.ots', upgradedProof)
const result = await client.verify(upgradedProof, hash)
if (result.valid) {
console.log(`Timestamp confirmed in Bitcoin block ${result.blockHeight}`)
console.log(`Block time: ${new Date(result.timestamp! * 1000).toISOString()}`)
} else {
console.error(`Verification failed: ${result.error}`)
}
Usage
Hashing files and data
Use the built-in helpers to compute SHA-256 without importing crypto yourself:
import { hashFile, hashBuffer } from '@otskit/client'
const hash = await hashFile('contract.pdf')
const hash = hashBuffer(Buffer.from('hello world'))
const hash = hashBuffer(new Uint8Array([...]))
Both return a 32-byte Buffer ready to pass directly to stamp().
Stamping data
stamp() accepts either a 32-byte Buffer or a 64-character hex string:
const proof = await client.stamp(await hashFile('contract.pdf'))
const proof = await client.stamp('a'.repeat(64))
Internally, stamp() prepends a 16-byte cryptographic nonce to each submission, builds a Merkle tree over all concurrent submissions, and serializes the result as a standard .ots file.
Upgrading a pending proof
Call upgrade() periodically until Bitcoin confirms the timestamp. It queries only the calendars embedded in the proof (validated against a whitelist), so your calendars option does not affect this step.
import { UpgradeError } from '@otskit/client'
try {
const upgradedProof = await client.upgrade(pendingProof)
} catch (err) {
if (err instanceof UpgradeError) {
console.log('Not confirmed yet, retry in 5 minutes')
}
}
Verifying a proof
verify() queries the Blockstream Esplora API to check the Bitcoin merkle root. Passing originalDataHash adds an extra integrity check that the proof was created for that specific hash.
const result = await client.verify(proof, originalHash)
if (result.valid) {
console.log(result.blockHeight)
console.log(result.blockHash)
console.log(result.timestamp)
} else {
console.log(result.error)
}
verify() always returns VerificationResult — it never throws for invalid proofs, only for unexpected network failures.
Error handling
import {
StampError,
UpgradeError,
ValidationError,
NetworkError,
CircuitBreakerError,
} from '@otskit/client'
try {
await client.stamp(hash)
} catch (err) {
if (err instanceof ValidationError) {
} else if (err instanceof StampError) {
console.log(`Succeeded: ${err.successfulSubmissions.map(s => s.calendar)}`)
console.log(`Failed: ${err.failedSubmissions.map(s => s.calendar)}`)
} else if (err instanceof CircuitBreakerError) {
} else if (err instanceof NetworkError) {
console.log(`HTTP status: ${err.status}`)
}
}
Cancellation with AbortController
You can cancel individual operations or set a client-wide signal:
const controller = new AbortController()
setTimeout(() => controller.abort(), 10_000)
const proof = await client.stamp(hash, { signal: controller.signal })
const clientController = new AbortController()
const client = new OpenTimestampsClient({ signal: clientController.signal })
clientController.abort()
Observability with a logger
Any object with debug, info, warn, and error methods works:
import pino from 'pino'
const client = new OpenTimestampsClient({
logger: pino({ level: 'debug' }),
})
Using console directly:
const client = new OpenTimestampsClient({ logger: console })
Monitoring circuit breakers
const state = client.getCircuitState('https://alice.btc.calendar.opentimestamps.org')
client.resetCircuit('https://alice.btc.calendar.opentimestamps.org')
client.resetAllCircuits()
Configuration
ClientOptions
const client = new OpenTimestampsClient({
calendars: [
'https://alice.btc.calendar.opentimestamps.org',
'https://bob.btc.calendar.opentimestamps.org',
'https://finney.calendar.eternitywall.com',
'https://btc.calendar.catallaxy.com',
],
minimumSuccessfulSubmissions: 2,
resilience: { ... },
logger: console,
signal: controller.signal,
})
ResilienceOptions
All fields are optional — unspecified fields fall back to the defaults shown.
resilience: {
totalTimeoutMs: 30_000,
connectTimeoutMs: 5_000,
retries: {
enabled: true,
maxAttempts: 3,
backoff: {
strategy: 'exponential',
initialDelayMs: 200,
maxDelayMs: 5_000,
jitter: 'full',
},
},
circuitBreaker: {
enabled: true,
failureThreshold: 5,
recoveryTimeoutMs: 15_000,
halfOpenMaxAttempts: 1,
},
}
Backoff strategies:
exponential | initialDelayMs × 2^(attempt - 1) |
linear | initialDelayMs × attempt |
constant | initialDelayMs |
Jitter modes:
full | Random value in [0, delay] — best for thundering-herd prevention |
equal | Random value in [delay/2, delay] |
none | Deterministic delay |
Circuit breaker states:
CLOSED ──(failureThreshold consecutive failures)──► OPEN
OPEN ──(recoveryTimeoutMs elapsed) ──► HALF_OPEN
HALF_OPEN ──(success) ──► CLOSED
HALF_OPEN ──(failure) ──► OPEN
API Reference
OpenTimestampsClient
Constructor
new OpenTimestampsClient(options?: ClientOptions)
stamp(hash, options?): Promise<Buffer>
Submits the hash to configured calendars and returns a serialized .ots proof.
hash | Buffer | string | SHA-256 hash (32-byte Buffer or 64-char hex string) |
options.signal | AbortSignal | Override the client-level signal for this call |
Throws ValidationError if the hash format is invalid.
Throws StampError if fewer than minimumSuccessfulSubmissions calendars accepted.
upgrade(proof, options?): Promise<Buffer>
Queries the calendars referenced in the proof for Bitcoin confirmations. Returns the updated proof if at least one calendar confirmed; otherwise throws UpgradeError.
proof | Buffer | Serialized .ots proof as returned by stamp() |
options.signal | AbortSignal | Override the client-level signal for this call |
Throws ValidationError if the proof is malformed.
Throws UpgradeError if no calendar has confirmed the timestamp yet.
verify(proof, originalDataHash?): Promise<VerificationResult>
Verifies a completed proof against the Bitcoin blockchain via Esplora. Never throws for invalid or incomplete proofs — failures are returned as { valid: false, error: '...' }.
proof | Buffer | Completed .ots proof with a Bitcoin attestation |
originalDataHash | Buffer | string | undefined | If provided, also checks that the proof was created for this hash |
Returns VerificationResult:
{
valid: boolean
blockHeight?: number
blockHash?: string
timestamp?: number
error?: string
}
getCircuitState(calendarUrl): CircuitState | undefined
Returns the current state of the circuit breaker for a calendar URL ('CLOSED', 'OPEN', 'HALF_OPEN', or undefined if not yet initialized).
resetCircuit(calendarUrl): void
Manually resets the circuit breaker for a calendar. Use this after a known outage is resolved.
resetAllCircuits(): void
Resets all circuit breakers across all calendars.
Errors
All errors extend OpenTimestampsClientError extends Error.
ValidationError | Invalid input (bad hash format, malformed proof, invalid URL) |
StampError | stamp() did not reach minimumSuccessfulSubmissions. Has .successfulSubmissions and .failedSubmissions arrays |
UpgradeError | No calendar confirmed the timestamp yet |
NetworkError | Network failure (timeout, all retries exhausted). Has .status?: number |
CircuitBreakerError extends NetworkError | Request rejected because the circuit is OPEN |
CommitmentNotFoundError extends NetworkError | Calendar returned 404 for a commitment |
CalendarResponseTooLargeError extends NetworkError | Calendar response exceeded the 10 KB size limit |
EsploraResponseError extends NetworkError | Esplora returned an invalid, malformed, or oversized response |
Utility functions
hashFile(path): Promise<Buffer>
Returns the SHA-256 hash of a file as a 32-byte Buffer. Reads the file as a stream — safe for large files.
import { hashFile } from '@otskit/client'
const hash = await hashFile('contract.pdf')
const proof = await client.stamp(hash)
hashBuffer(data): Buffer
Returns the SHA-256 hash of a Buffer or Uint8Array synchronously.
import { hashBuffer } from '@otskit/client'
const hash = hashBuffer(Buffer.from('my data'))
Advanced exports
These are available for custom integrations and advanced use cases.
CalendarClient
Low-level client for a single OTS calendar server.
import { CalendarClient, ResilientNetworkLayer, DEFAULT_RESILIENCE } from '@otskit/client'
const network = new ResilientNetworkLayer(DEFAULT_RESILIENCE)
const calendar = new CalendarClient('https://alice.btc.calendar.opentimestamps.org', network)
const timestamp = await calendar.submit(digest)
const upgraded = await calendar.getTimestamp(digest)
EsploraClient
Client for querying a Bitcoin block explorer compatible with the Esplora API.
import { EsploraClient, ResilientNetworkLayer, DEFAULT_RESILIENCE, PUBLIC_ESPLORA_URL } from '@otskit/client'
const network = new ResilientNetworkLayer(DEFAULT_RESILIENCE)
const esplora = new EsploraClient(network, { url: PUBLIC_ESPLORA_URL })
const blockHash = await esplora.blockHash(850_000)
const blockHeader = await esplora.block(blockHash)
verifyTimestampAttestation
Verifies a single Attestation (Bitcoin or Litecoin) against a block explorer.
import { verifyTimestampAttestation } from '@otskit/client'
const blockTime = await verifyTimestampAttestation(digest, attestation, esploraClient)
UrlWhitelist
Wildcard URL allowlist used internally to validate calendar URLs in upgrade proofs.
import { UrlWhitelist } from '@otskit/client'
const wl = new UrlWhitelist([
'https://*.calendar.opentimestamps.org',
'https://my-calendar.example.com',
])
wl.contains('https://alice.btc.calendar.opentimestamps.org')
wl.contains('https://evil.example.com')
ResilientNetworkLayer
The full timeout + retry + circuit-breaker stack as a standalone class.
import { ResilientNetworkLayer, DEFAULT_RESILIENCE } from '@otskit/client'
const network = new ResilientNetworkLayer(DEFAULT_RESILIENCE, logger)
const response = await network.request(calendarUrl, {
url: 'https://...',
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: new Uint8Array([...]),
})
Constants
import {
DEFAULT_CALENDARS,
DEFAULT_RESILIENCE,
DEFAULT_CALENDAR_WHITELIST,
DEFAULT_AGGREGATORS,
PUBLIC_ESPLORA_URL,
MAX_CALENDAR_RESPONSE_SIZE,
MAX_ESPLORA_RESPONSE_SIZE,
} from '@otskit/client'
Contributing
Contributions are welcome. Please open an issue before starting significant work so we can align on approach.
Setup
git clone https://github.com/OTSkit/OTSkit-client.git
cd OTSkit-client
npm install
npm test
npm run lint
npm run build
Testing
The test suite uses Vitest, MSW for HTTP mocking, and fast-check for property-based testing. All tests run in Node.js (no browser required).
npm test
npm run test:watch
npm test -- --coverage
Commit convention
This repository uses Conventional Commits. Releases are automated via semantic-release.
Code style
- TypeScript strict mode
- ESLint + Prettier (run
npm run format before pushing)
- Fail-closed: all external input is validated at the boundary
- No third-party runtime dependencies (
@otskit/core is the only dependency)
Links
License
MIT © OTSkit contributors — see LICENSE.