Python SDK
github.com/CredenShare/credenshare-sdk-python
Python 3.9 and above, with two runtime dependencies: cryptography and httpx.
Installing
pip install credenshare
Installing from the repository also works, if you want a specific tag or an unreleased fix:
pip install "git+https://github.com/CredenShare/credenshare-sdk-python@v0.1.3"
Quickstart
import os
from credenshare import CredenShare
with CredenShare(os.environ["CREDENSHARE_KEY"]) as crs:
share = crs.shares.create(
title="Staging deploy credentials",
fields=[
{"key": "Username", "value": "deploy-bot", "type": "text"},
{"key": "Password", "value": "correct horse", "type": "password"},
],
)
print(share.link)
# https://crs.sh/aB3dEf12#1xK9...
The client is a context manager, which closes the underlying httpx client on exit. Use it that way unless you are holding one for the life of the process.
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. 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
crs.shares.create(
title="Production database",
fields=[{"key": "Password", "value": "s3cr3t", "type": "password"}],
passcode="hunter2",
expired_at="2026-09-01T00:00:00Z",
access_counts_left=3, # readable three times, max 10000
timed_view=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. Passing both custody=True and an explicit item_key_wrap raises InvalidFieldError, which subclasses both CredenShareError and ValueError, so either kind of except catches it.
Requires the shares:write scope.
Listing and expiring
for row in crs.shares.list(limit=50):
print(row.short_code, row.expired_at)
for row in crs.shares.iter_all(): # every page, not just the first
print(row.short_code)
crs.shares.expire("aB3dEf12") # irreversible
list() returns a ShareList, which subclasses list, so it iterates directly and also carries the paging fields. iter_all() walks every page, and specifically does not stop on a short middle page — the way a hand-rolled paging loop usually goes wrong. There is a regression test named after that bug.
has_more no longer answers False on a page the server failed to count — it falls back through total_pages, then total, then the page's own row count — so this client no longer stops at page one and calls it the whole account.
It can fail rather than loop. A response echoing a page number other than the one requested raises ApiError, because a server doing that makes progress unobservable, and the walk stops at a hard ceiling of 100,000 pages rather than returning a partial result silently. The constant is internal — only the Rust crate re-exports its equivalent.
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 PermissionError_ — the most common first surprise.
Both return metadata only, never content and never a key. 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 raises NotFoundError. A share you expired and one that never existed are indistinguishable afterwards.
Verifying webhooks
from credenshare import webhooks
@app.post("/hooks/credenshare")
async def hook(request):
try:
webhooks.verify(
await request.body(), # the RAW bytes
request.headers["X-CredenShare-Signature"],
secrets=WEBHOOK_SECRET,
)
except webhooks.WebhookVerificationError:
return Response(status_code=400)
Verify the raw body. 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:
webhooks.verify(body, header, secrets=[NEW_SECRET, OLD_SECRET])
verify returns True or raises; it never returns 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 ±5-minute replay window is exported as DEFAULT_TOLERANCE_SECONDS, and the header name as webhooks.SIGNATURE_HEADER. _SIGNATURE_HEADER is kept as an alias, so code importing the private name still works.
Configuration
CredenShare(
credential,
base_url="https://api.credenshare.io/v1",
link_origin="https://crs.sh",
timeout=30.0,
max_retries=2,
transport=None, # any httpx.BaseTransport
)
link_origin changes only the links create() hands back; it is never sent to the API. transport accepts an httpx.BaseTransport, a testing seam Node (an injectable fetch) and Go (Options.HTTPClient) also offer. Rust is the only one of the four without one. The SDK reads no environment variables — os.environ above is your own read.
Rough edges
Real behaviour worth knowing before it surprises you.
- Short codes are percent-encoded now, so
/,?and#cannot make a crafted value leave/v1/shares/. They are still opaque — pass only what the API gave you. DEFAULT_MAX_RETRIESis reachable only ascredenshare.client.DEFAULT_MAX_RETRIES, not from the package root. Node, Go and Rust all export theirs.__version__reports the wrong number. It reads0.1.0in the published0.1.3package — hardcoded in__init__.pyand not bumped with the release. Read the installed distribution's metadata if you need the real version.PermissionError_has a trailing underscore, to avoid shadowing the Python builtin. CatchingPermissionErrorcatches the wrong thing entirely.- The idempotency key is generated per call and never surfaced. It protects a same-process network retry, which the client performs itself. It does not protect a re-run — a job that crashes after POSTing generates a fresh UUID next time and mints a second copy of the secret. Pass your own if you need that, and note that doing so does not make a second
create()a no-op: salt and IV are fresh per call, so the body differs and the API answers409. ServiceUnavailableErrornow means only what its docstring says — a genuine503, or exhausted connect failures, where nothing was created. An exhausted read timeout, where the request was written and the server may have committed, raisesDeliveryUnknownErrorcarryingattempts. It arrives withstatus,codeandrequest_idallNone, because no response was ever read, and it extendsApiError, so anexcept ApiErrorcatches it.AuthenticationErroris narrower than it sounds. A revoked or unknown credential arrives as HTTP 403 and therefore asPermissionError_.print(share)does not show the link.Share.__repr__deliberately withholds it; printshare.linkwhen you actually want the secret. Node behaves the same way now — itsShareis a class whosetoString,toJSONand inspect hook all redact the link.- A webhook secret with a trailing newline fails. The blank check trims, but the HMAC is keyed with the untrimmed string. Python, Go and Rust behave this way — trim it yourself. Node now refuses such a secret by name instead of blaming the signature.
Checking your build
python -m credenshare.conformance
# 24 passed. This installation conforms to the wire specification.
Add -v for one line per vector. It needs no test runner and no dev dependencies, and exits non-zero on failure, so it works as a deployment gate — run it in the environment that will actually do the encrypting. See Conformance vectors.