Go server SDK
Module github.com/keenauth/keenauth-go is the Go client of the KeenAuth management API. Typed methods for every /v1 resource are generated from the OpenAPI document (package management, embedded in keenauth.Client). The hand-written core adds authentication, retries, idempotency keys, typed errors and deprecation warnings. The module uses only the standard library. It is not published yet.
For sign-in in a net/http app, see Go net/http sessions. To protect your own API, see Protecting your API.
Create a client with an API key
import (
"context"
"fmt"
"os"
keenauth "github.com/keenauth/keenauth-go"
)
c, err := keenauth.NewClient("https://api.eu.keenauth.com/v1", os.Getenv("KEENAUTH_API_KEY"))
if err != nil {
panic(err)
}
user, _, err := c.UsersAPI.GetUser(context.Background(), "usr_01j9zzzzzzzzzzzzzzzzzzzz").Execute()
if err != nil {
panic(err)
}
fmt.Println(user.GetEmail())
The base URL is your region's API address followed by /v1. The key (idp_sk_… or idp_rk_…) is sent only in the Authorization header and only to that address; a redirect to another host or to plain http never carries it. Plain http is accepted only for localhost.
Options:
keenauth.WithHTTPClient(h): your ownhttp.Client(its transport is wrapped, not changed).keenauth.WithTimeout(d): total time limit of a call, retries included (default 30 seconds).keenauth.WithClientName("my-tool", "1.4.0"): adds your tool toUser-AgentandIdp-Client.keenauth.WithLogger(l): the*slog.Loggerfor SDK warnings (defaultslog.Default()).
Authenticate with private_key_jwt
Instead of a static key, a management application can sign in with its own key pair. The SDK gets a management token from your environment's token endpoint, keeps it in memory and replaces it 60 seconds before it expires. Concurrent calls share one token request.
- In the dashboard, create a management application and register its public key (as a JWKS URL or directly; up to 3 active keys).
- Keep the private key on your server, for example in a secret file or a KMS.
import (
"os"
keenauth "github.com/keenauth/keenauth-go"
"github.com/keenauth/keenauth-go/assertion"
)
pemBytes, err := os.ReadFile("/run/secrets/keenauth-mgmt-key.pem")
if err != nil {
panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes)
if err != nil {
panic(err)
}
c, err := keenauth.NewClientWithPrivateKeyJWT("https://api.eu.keenauth.com/v1", keenauth.PrivateKeyJWT{
Issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
ClientID: "app_01j9zzzzzzzzzzzzzzzzzzzz",
Key: key,
KeyID: "mgmt-key-2026-11", // the kid you registered; optional with a single key
Scope: "users:read users:write", // optional; default: the grant's default scopes
})
What the SDK sends:
POST <Issuer>/oauth2/tokenwithgrant_type=client_credentials,client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer, a newclient_assertionandresourceset to the API address (https://api.eu.keenauth.com; change it withAudience).- The assertion is a JWT with
issandsubset to your client ID,audset to the issuer, a randomjtiand a lifetime of 2 minutes. It is signed with ES256 (P-256 key), EdDSA (Ed25519), RS256 or PS256 (RSA, 2048 bits or more; setAlgorithm: "PS256"for PSS).
Key is any crypto.Signer, so a key held in a KMS or HSM works without exporting it. The management token lives 10 minutes. If the API answers 401, the SDK drops the cached token and the next call gets a new one.
A refused token request returns a *keenauth.TokenError with the HTTP status and the OAuth error code, for example invalid_client for an unknown key or a wrong KeyID. It never contains the assertion or a token:
var te *keenauth.TokenError
if errors.As(err, &te) {
log.Printf("token request refused: %d %s", te.Status, te.Code)
}
Retries and idempotency keys
The SDK retries a call at most 3 times in total, and only when both are true:
- the answer is
429,502,503or504, or the connection failed (a network error, not a certificate error); - the method is
GET,PUTorDELETE, or aPOSTwith anIdempotency-Key.
Every POST operation that accepts an Idempotency-Key (all of them except the side-effect-free users:search and …:preview calls and users/{id}:unlock) gets a new UUIDv4 key, and every retry of that call sends the same key and the same body, so the operation runs at most once. To make a call idempotent across your own restarts, pass your own key (16 to 64 characters of A-Z a-z 0-9 _ -), for example .IdempotencyKey(orderKey); the SDK never replaces it. A PATCH that carries If-Match (every versioned update) is retried on 429 only: the rate limiter refuses a request before it runs, and If-Match keeps a change from being applied twice. Any other PATCH is never retried.
Between attempts the SDK waits as long as Retry-After says (seconds or an HTTP date). Without it, it waits 0.5 seconds, then 1 second, each with ±25 % jitter. If Retry-After asks for more than 60 seconds, or the wait would pass your context's deadline, the SDK returns the response instead of waiting. Cancelling the context stops the wait.
Iterate over a list
List methods return one page: data and list_metadata with the before and after cursors. keenauth.All follows after and yields every item. It fetches the next page only when you reach it, so breaking out of the loop stops further requests. Set the page size and filters on the request as usual:
for u, err := range keenauth.All(c.UsersAPI.ListUsers(ctx).Limit(100).Status("active")) {
if err != nil {
return err // the iteration ends after an error
}
fmt.Println(u.GetId(), u.GetEmail())
}
keenauth.AllBefore follows before instead, for example towards newer entries of ListLogs. Every generated list method works with both, except ListEvents of the Events API, which pages with next_cursor and has_more. A range-over-func loop needs Go 1.23 or later.
Verify webhooks
Package github.com/keenauth/keenauth-go/webhook checks the signature of a webhook delivery (Standard Webhooks). Give it the endpoint's signing secret (idp_whsec_…) and pass the raw body exactly as received:
import (
"io"
"net/http"
"os"
"github.com/keenauth/keenauth-go/webhook"
)
verifier, err := webhook.NewVerifier([]string{os.Getenv("KEENAUTH_WEBHOOK_SECRET")})
if err != nil {
panic(err) // webhook.ErrSecret: the secret is not well formed
}
http.HandleFunc("POST /webhooks/keenauth", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if err := verifier.Verify(r.Header, body); err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest)
return
}
// Handle the event. Store webhook-id and skip a delivery you have already processed.
w.WriteHeader(http.StatusNoContent)
})
Verify accepts a delivery when webhook-timestamp is within 5 minutes of now (in both directions) and one of the v1 signatures in webhook-signature matches. Signatures are compared in constant time, and other signature versions are ignored. The error tells you why a delivery was refused; each matches one of webhook.ErrHeaders (a header is missing), webhook.ErrTimestamp, webhook.ErrSignature or webhook.ErrSecret with errors.Is.
When you rotate the secret, pass both secrets, new and old, until the old one stops being used: webhook.NewVerifier([]string{newSecret, oldSecret}). During the rotation KeenAuth may also send two signatures in one header; either one is enough.
Typed events and claims
Package github.com/keenauth/keenauth-go/events has one struct per customer event type, generated from the public event schemas. webhook.Verifier.VerifyEvent verifies a delivery and parses it; events.Parse parses a body you have already verified. Use a type switch to get the fields of one type:
ev, err := verifier.VerifyEvent(r.Header, body)
if err != nil {
http.Error(w, "invalid webhook", http.StatusBadRequest) // webhook.Err… or events.ErrParse
return
}
switch e := ev.(type) {
case *events.UserCreated:
log.Printf("user %s created", e.Data.Object.ID)
case *events.EmailSendingSuspended:
log.Printf("sending suspended: %s", e.Data.Object.Reason)
case *events.Unknown:
// A type this SDK version does not know yet. e.Type and e.Raw() are still available.
}
Parsechecks the envelope (id,type,schema_version,environment_id,occurred_at,recorded_at,seq,data,context) and the members the type's schema declares. A missing or wrong-typed member returns a*events.ParseErrorwith its path, such asdata.object.reason.- An unknown type, or a known type with a
schema_versionthis SDK does not model, is returned as*events.Unknown. It is not an error. - Members that the schema does not declare are kept:
Raw()returns the whole event andObjectJSON()returnsdata.object(in full payload mode, the management API representation of the resource). - Values of enumerated fields are not checked against the known values, so a new value from the server does not break parsing.
Package github.com/keenauth/keenauth-go/claims types the claims of a verified token: claims.ParseIDToken for an ID token (OpenID Connect Core) and claims.ParseAccessToken for an access token (RFC 9068). They check claim types but verify nothing, so call them on the Claims map that package verify returns:
at, err := v.VerifyAccessToken(ctx, token, nil)
if err != nil {
return err
}
c, err := claims.ParseAccessToken(at.Claims)
if err != nil {
return err
}
log.Printf("client %s, org %s, roles %v", c.ClientID, c.OrgID, c.OrgRoles)
_ = c.Custom["tenant_tier"] // your own claims
aud is always a slice (claims.Audience). Claims that are not part of the contract are your own claims and are kept in Custom.
Errors
An API error is an RFC 9457 problem document. keenauth.AsProblem(err) returns it with the machine-readable Code. An unknown code is returned as it is, so new server codes do not break your client:
if _, _, err := c.UsersAPI.GetUser(ctx, id).Execute(); err != nil {
if pe, ok := keenauth.AsProblem(err); ok && pe.Code == "not_found" {
// …
}
}
Deprecation warnings
When a response carries a Deprecation (RFC 9745) or Sunset (RFC 8594) header, the SDK logs one warning per operation and header value for the whole process, at WARN level on the logger you set with WithLogger. The warning names the method and the path with identifiers replaced by {id}, the header values and any Link with rel="deprecation" or rel="sunset". It never contains IDs, tokens or keys, and the call itself is not affected.
level=WARN msg="keenauth: the API operation is deprecated" operation="GET /users/{id}" deprecation=@1767225600 sunset="Wed, 01 Jul 2026 00:00:00 GMT"