Skip to content
CredenShare
SDKs

Node SDK

The official TypeScript client — encrypts locally, assembles the link, verifies webhooks. ESM only, no runtime dependencies.

github.com/CredenShare/credenshare-sdk-node

Runs on Node 20+, and also on Deno, Bun, Cloudflare Workers and browsers — everything goes through globalThis.crypto.subtle rather than node:crypto, which is what keeps it working on the runtimes it is most useful in. There are no runtime dependencies.

Installing

npm install @credenshare/sdk

Installing straight from the repository also works, if you want a specific commit or an unreleased fix — dist/ is not committed, but the package's prepare hook runs the build and npm runs prepare for a git install:

npm install github:CredenShare/credenshare-sdk-node#v0.1.3

If you would rather work against a clone — to read the source, or to try a change — build it and link it instead:

git clone https://github.com/CredenShare/credenshare-sdk-node
cd credenshare-sdk-node
npm install && npm run build && npm link

then npm link @credenshare/sdk from your project. A built clone on its own is not reachable from your project; npm link is what makes the import resolve.

Quickstart

share.ts
import { CredenShare } from '@credenshare/sdk'

const crs = new CredenShare(process.env.CREDENSHARE_KEY!)

const share = await crs.shares.create({
  title: 'Staging deploy credentials',
  fields: [
    { key: 'Username', value: 'deploy-bot', type: 'text' },
    { key: 'Password', value: 'correct horse', type: 'password' },
  ],
})

console.log(share.link)
// https://crs.sh/aB3dEf12#1xK9...

CredenShare is a named export, and the constructor takes the credential string positionally.

The package is ESM only — there is no CommonJS build and no require condition in exports. On Node 20.19+ and 22.12+ that is not a barrier: those releases load an ESM graph through require, so require('@credenshare/sdk') returns the module namespace and works. On older 20.x it throws ERR_REQUIRE_ESM, and a dynamic await import() is the way in.

That link is the secret. The key rides in the fragment, which browsers never transmit. Anyone holding the link can read the content; we cannot, and cannot recover it for you.

The field object

Each field is { key, value, type }.

key is the visible label, not an identifier — it is what the recipient reads. It is not label, name or title; those spellings encrypt, post, decrypt and render perfectly, with every field blank and nothing erroring anywhere. The SDK refuses them rather than letting the mistake through.

type is one of text, password, date, multiline, markdown, source_code, exported as FIELD_TYPES. Note that validation checks only that type is present, not that it is a member of that tuple — a typo encrypts and misrenders silently, so check against the constant if the value is dynamic.

selectedProgrammingLanguage and filename are optional, and this client preserves them along with any other member you add.

Creating

await crs.shares.create({
  title: 'Production database',
  fields: [{ key: 'Password', value: 's3cr3t', type: 'password' }],
  passcode: 'hunter2',
  expiredAt: '2026-09-01T00:00:00Z',
  accessCountsLeft: 3,   // readable three times, max 10000
  timedView: 60,         // visible for 60s once opened
})

A passcode is mixed into the content key derivation, not just checked by the server — so it is not a server-side gate you could bypass, and a passcode-protected share cannot be opened from the link alone. The server receives only a one-way verifier. Send the link and the passcode over different channels.

Pass custody: true to also wrap the content key to the custody public key derived from your credential's third part, which keeps the share readable from your dashboard rather than only from its link. The wrap is computed locally and the custody secret never leaves your machine; the response's custody field reports stored or failed.

Requires the shares:write scope.

Listing and expiring

const page = await crs.shares.list({ limit: 50 })

for await (const row of crs.shares.iterateAll()) {
  console.log(row.shortCode, row.expiredAt)
}

await crs.shares.expire('aB3dEf12')

list and get need shares:read; create and expire need shares:write. There is no hierarchy between them, so a key minted with only shares:write fails on list() with a PermissionError — the most common first surprise.

Both return metadata only, never content and never a key. ShareSummary deliberately has no title. A short code belonging to another account reports exactly as one that does not exist.

expire removes the share rather than flagging it, so a later get throws NotFoundError. A share you expired and one that never existed are indistinguishable afterwards.

readLink() exists on the class and always throws. It is there to be discoverable, not usable — see what none of them do.

Verifying webhooks

import express from 'express'
import { webhooks } from '@credenshare/sdk'

app.post('/hooks/credenshare', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    await webhooks.verify(req.body, req.get('X-CredenShare-Signature')!, process.env.WEBHOOK_SECRET!)
  } catch {
    return res.sendStatus(400)
  }
  // ...
})

Verify the raw body. express.raw(), never express.json(). Re-serialising parsed JSON changes the bytes — key order, spacing, escapes — and the signature will not match. It is the most common reason a correct integration looks broken.

Pass both secrets while rotating. For 24 hours after a rotation, deliveries carry both signatures:

await webhooks.verify(body, header, [NEW_SECRET, OLD_SECRET])

verify resolves to true or rejects; it never resolves to false, because a falsy return is too easy to drop with an if and no else, which yields a receiver that accepts everything while looking like it checks.

The header name is exported as webhooks.SIGNATURE_HEADER, and the ±5-minute replay window as webhooks.DEFAULT_TOLERANCE_SECONDS.

Configuration

new CredenShare(credential, {
  baseUrl: 'https://api.credenshare.io/v1',
  linkOrigin: 'https://crs.sh',
  timeoutMs: 30_000,
  maxRetries: 2,
  fetch: myFetch,          // injectable, for tests
})

linkOrigin changes only the links create() hands back; it is never sent to the API. The SDK reads no environment variables — process.env in the examples above is your own read.

Rough edges

Real behaviour worth knowing before it surprises you.

  • The error hierarchy changed, if you wrote against the old one. Field validation now throws InvalidFieldError where it threw a bare TypeError, and WebhookVerificationError extends CredenShareError — so a blanket catch (e) { if (e instanceof CredenShareError) … } now reaches both, including the key-versus-label mistake the SDK exists to catch. A catch written for TypeError no longer matches.
  • The idempotency key is generated per call and handed back as share.idempotencyKey, so the documented recovery — repeat the identical request with the same key — is reachable. It still does not make a second create() a no-op: salt and IV are fresh per call, so the body differs and the API answers 409. And a CI job that crashes after POSTing still mints a second copy on re-run unless you persist that key and pass it back.
  • Only transport failures are retried, never an HTTP status. A 500 is surfaced because it may have committed.
  • ApiError carries the server's additional_data on a validation failure, so you can see which field was rejected rather than only that something was.
  • Transport failures no longer arrive as ServiceUnavailableError. That class is now only a real HTTP 503, which does mean nothing was created. Exhausted retries raise NetworkError when nothing reached the API at all, and DeliveryUnknownError when headers had already arrived and the outcome is genuinely unknown. Both carry attempts and both extend CredenShareError.
  • VERSION reports the wrong number. The exported constant reads 0.1.0 in the published 0.1.3 package — it is hardcoded separately from package.json and was not bumped. Read the version from package.json if you need it.
  • iterateAll can fail rather than loop. It walks until a page comes back short, and refuses a response that echoes a page number other than the one requested — a server doing that makes progress unobservable, so it raises ApiError instead of continuing. There is also a hard ceiling of 100,000 pages, which raises rather than returning a partial result silently. The constant is internal — only the Rust crate re-exports its equivalent.
  • retryAfter reads both forms of the header — delta-seconds, and an HTTP-date converted to whole seconds from now. It is undefined, never 0, when the header is absent or unreadable, so a caller waiting on it notices rather than retrying immediately. Nothing sleeps on a 429 for you.
  • Logging a Share no longer spills the link. It is a class whose toString, toJSON and util.inspect hook all render Share(<shortCode>, link redacted), so console.log(share) and JSON.stringify(share) are safe — matching Python, Go and Rust. share.link and share.contentKey are still readable by name, which is the only way you should reach them.
  • A webhook secret with surrounding whitespace is refused by name. verify rejects any secret that differs from its own trim(), with a WebhookVerificationError saying so, rather than letting the HMAC fail and blaming the signature. Python, Go and Rust still do the old thing, so this is the one client that tells you what is actually wrong.

Checking your build

node dist/conformance-cli.js
# 24 passed. This installation conforms to the wire specification.

Once the package is published, the binary is reachable as npx --package @credenshare/sdk credenshare-conformance — the bin name and the package name differ, so a bare npx credenshare-conformance would look for a package that does not exist. It needs no test runner and exits non-zero on failure, so it works as a deployment gate.

The checks are also available programmatically, from the ./conformance subpath, if you would rather assert them inside your own test suite than shell out:

import { run } from '@credenshare/sdk/conformance'

The fixture ships inside the artifact as conformance-vectors.json — the same bytes as the other three clients, under a different filename. See Conformance vectors.