Node SDK
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
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.
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
InvalidFieldErrorwhere it threw a bareTypeError, andWebhookVerificationErrorextendsCredenShareError— so a blanketcatch (e) { if (e instanceof CredenShareError) … }now reaches both, including thekey-versus-labelmistake the SDK exists to catch. Acatchwritten forTypeErrorno 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 secondcreate()a no-op: salt and IV are fresh per call, so the body differs and the API answers409. 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.
ApiErrorcarries the server'sadditional_dataon 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 HTTP503, which does mean nothing was created. Exhausted retries raiseNetworkErrorwhen nothing reached the API at all, andDeliveryUnknownErrorwhen headers had already arrived and the outcome is genuinely unknown. Both carryattemptsand both extendCredenShareError. VERSIONreports the wrong number. The exported constant reads0.1.0in the published0.1.3package — it is hardcoded separately frompackage.jsonand was not bumped. Read the version frompackage.jsonif you need it.iterateAllcan 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 raisesApiErrorinstead 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.retryAfterreads both forms of the header — delta-seconds, and an HTTP-date converted to whole seconds from now. It isundefined, never0, 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
Shareno longer spills the link. It is a class whosetoString,toJSONandutil.inspecthook all renderShare(<shortCode>, link redacted), soconsole.log(share)andJSON.stringify(share)are safe — matching Python, Go and Rust.share.linkandshare.contentKeyare still readable by name, which is the only way you should reach them. - A webhook secret with surrounding whitespace is refused by name.
verifyrejects any secret that differs from its owntrim(), with aWebhookVerificationErrorsaying 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.