Go SDK
github.com/CredenShare/credenshare-sdk-go
Go 1.21 and above. Standard library only — HKDF is forty lines of crypto/hmac rather than a module, because a security client earning a dependency for that is a poor trade.
Installing
go get github.com/CredenShare/credenshare-sdk-go
A Go module resolves straight from its repository through the proxy, so there was never a registry to wait for. Name a version if you want one — @v0.1.3 — otherwise go get takes the latest tag.
@v/list endpoint lags behind a fresh tag by a while, so a version can be missing from that listing and still resolve perfectly — go get and @latest fetch on demand. If a brand-new version appears absent, ask for it by name rather than assuming it failed to publish.Quickstart
package main
import (
"context"
"fmt"
"os"
credenshare "github.com/CredenShare/credenshare-sdk-go"
)
func main() {
client, err := credenshare.New(os.Getenv("CREDENSHARE_KEY"), nil)
if err != nil {
panic(err)
}
share, err := client.CreateShare(context.Background(), credenshare.CreateParams{
Title: "Staging deploy credentials",
Fields: []credenshare.Field{
{Key: "Username", Value: "deploy-bot", Type: "text"},
{Key: "Password", Value: "correct horse", Type: "password"},
},
})
if err != nil {
panic(err)
}
fmt.Println(share.Link)
// https://crs.sh/aB3dEf12#1xK9...
}
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.
Note that fmt.Println(share) prints <Share aB3dEf12 (link withheld)> — the String method deliberately withholds the link, so %v in a log will not leak it. Print share.Link when you actually want it.
The field object
Field.Key is the visible label, not an identifier — it is what the recipient reads. Go's types stop the label: spelling that catches the dynamic clients, but a caller unmarshalling from JSON with the wrong member name lands in the same place: Key empty, every field rendered blank, nothing erroring. ValidateFields refuses that before anything is sent.
Type is one of text, password, date, multiline, markdown, source_code, exported as FieldTypes. Validation checks only that Type is present, not that it is a member of that set.
Field carries an Extra map[string]json.RawMessage overflow map, so members this client does not name — selectedProgrammingLanguage, filename, anything a newer sender adds — are kept on decrypt and written back on re-encrypt. The cost is that Field is no longer comparable — f1 == f2 and map[Field]T do not compile against this version. Use Field.Equal(other), which compares the three known members plus Extra as raw bytes, and key maps on Field.Key.Creating
share, err := client.CreateShare(ctx, credenshare.CreateParams{
Title: "Production database",
Fields: []credenshare.Field{{Key: "Password", Value: "s3cr3t", Type: "password"}},
Passcode: "hunter2",
})
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.
Set 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. Credential.WrapToCustody computes it locally; the custody secret never leaves your machine.
Requires the shares:write scope.
Listing and expiring
page, err := client.ListShares(ctx, 50, 1)
fmt.Println(page.Total, page.HasMore())
err = client.IterateShares(ctx, 100, func(s credenshare.ShareSummary) error {
fmt.Println(s.ShortCode, s.ExpiredAt)
return nil
})
err = client.ExpireShare(ctx, "aB3dEf12")
ListShares and GetShare need shares:read; CreateShare and ExpireShare need shares:write. There is no hierarchy between them, so a key minted with only shares:write fails on ListShares with a permission error — 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.
ExpireShare removes the share rather than flagging it, so a later GetShare returns ErrNotFound. A share you expired and one that never existed are indistinguishable afterwards.
Verifying webhooks
import "github.com/CredenShare/credenshare-sdk-go/webhooks"
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body) // the RAW bytes, before any decoding
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := webhooks.Verify(body, r.Header.Get(webhooks.SignatureHeader),
[]string{os.Getenv("WEBHOOK_SECRET")}, nil); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// ...
}
Verify the raw body. Read it with io.ReadAll and verify those bytes. Re-serialising decoded JSON changes them — key order, spacing, escapes — and the signature will not match.
Pass both secrets while rotating. For 24 hours after a rotation, deliveries carry both signatures:
webhooks.Verify(body, header, []string{newSecret, oldSecret}, nil)
Verify returns only an error. A (bool, error) signature invites ok, _ := Verify(...), and a receiver that ignores the error accepts everything while looking like it checks.
The header name is webhooks.SignatureHeader and the ±5-minute window is webhooks.DefaultTolerance. Options.Tolerance is a *time.Duration, so nil means the default and zero genuinely means zero: use webhooks.ToleranceOf(30 * time.Second) for a custom window, or webhooks.NoTolerance() to demand an exact timestamp.
Configuration
client, err := credenshare.New(credential, &credenshare.Options{
BaseURL: "https://api.credenshare.io/v1",
LinkOrigin: "https://crs.sh",
HTTPClient: myClient,
MaxRetries: credenshare.Retries(2),
Timeout: 30 * time.Second,
})
LinkOrigin changes only the links CreateShare hands back; it is never sent to the API. The SDK reads no environment variables — os.Getenv above is your own read.
Options.Timeout sets the per-attempt timeout, defaulting to credenshare.DefaultTimeout (30 seconds). It also applies to an HTTPClient you supply that has no timeout of its own; one that already sets a timeout keeps it.
Rough edges
Real behaviour worth knowing before it surprises you.
Fieldis no longer comparable. The overflow map that preserves optional members meansf1 == f2andmap[Field]Tstop compiling.Field.Equal(other)replaces==; key maps onField.Key.errors.Is(err, ErrAPI)matches every refusal, and the specific sentinels —ErrNotFound,ErrRateLimited,ErrIdempotencyConflictand the rest — still match throughUnwrap. Useerrors.Asfor*credenshare.APIErrorwhen you want to read.Statusand.Code.- 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 key next time and mints a second copy of the secret. Set
IdempotencyKeyyourself if you need that, and note that doing so does not make a secondCreateSharea no-op: salt and IV are fresh per call, so the body differs and the API answers409withErrIdempotencyConflict. IterateSharescan fail for a paging reason. Ask for page 3, get a response claiming page 1, and it stops with an error wrappingErrAPIrather than looping. It otherwise walks until a page comes back short, or until the reported total is reached — unlike Node, Python and Rust it carries no absolute page ceiling, which only matters against a server that returns full pages indefinitely.- Only network failures are retried, never an HTTP status. A 500 is surfaced because it may have committed.
ErrServiceUnavailableis now only a genuine503— the API answering that nothing was created, safe to retry. Exhausted transport retries wrapErrDeliveryUnknowninstead, becauseDoreturns once headers arrive, so a failure after that can still mean the request was processed.ErrAuthenticationis narrower than it sounds. A revoked or unknown credential arrives as HTTP 403 and therefore as the permission error.ReadLinkalways fails — by design, since the recipient path is guarded by proof-of-work and captcha checks bearer auth would skip. It wrapscredenshare.ErrNotSupported, soerrors.Ismatches it.- A webhook secret with a trailing newline fails. The blank check trims, but the HMAC is keyed with the untrimmed string. Go, Python and Rust behave this way — trim it yourself. Node now refuses such a secret by name.
Checking your build
From your own module, with nothing cloned:
go run github.com/CredenShare/credenshare-sdk-go/cmd/credenshare-conformance@latest
# 24 passed. This installation conforms to the wire specification.
Inside a clone of the repository, go run ./cmd/credenshare-conformance does the same thing. Add -v for one line per vector. The fixture is embedded with //go:embed, so it travels with the compiled binary; the command exits non-zero on failure and works as a deployment gate. See Conformance vectors.