
# 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](/docs/sdk/go-nethttp). To protect your own API, see [Protecting your API](/docs/sdk/protect-api).

## Create a client with an API key

```go
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 own `http.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 to `User-Agent` and `Idp-Client`.
- `keenauth.WithLogger(l)`: the `*slog.Logger` for SDK warnings (default `slog.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.

1. In the dashboard, create a management application and register its public key (as a JWKS URL or directly; up to 3 active keys).
2. Keep the private key on your server, for example in a secret file or a KMS.

```go
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/token` with `grant_type=client_credentials`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, a new `client_assertion` and `resource` set to the API address (`https://api.eu.keenauth.com`; change it with `Audience`).
- The assertion is a JWT with `iss` and `sub` set to your client ID, `aud` set to the issuer, a random `jti` and a lifetime of 2 minutes. It is signed with ES256 (P-256 key), EdDSA (Ed25519), RS256 or PS256 (RSA, 2048 bits or more; set `Algorithm: "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:

```go
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`, `503` or `504`, or the connection failed (a network error, not a certificate error);
- the method is `GET`, `PUT` or `DELETE`, or a `POST` with an `Idempotency-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:

```go
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:

```go
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:

```go
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.
}
```

- `Parse` checks 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.ParseError` with its path, such as `data.object.reason`.
- An unknown type, or a known type with a `schema_version` this 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 and `ObjectJSON()` returns `data.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:

```go
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:

```go
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.

```text
level=WARN msg="keenauth: the API operation is deprecated" operation="GET /users/{id}" deprecation=@1767225600 sunset="Wed, 01 Jul 2026 00:00:00 GMT"
```
