API structure generated by ogen-go with AuthFacade, EncryptionService, Client and in memory TokenProvider.
Very early project status - not ready for production use.
- Sending invoices — interactively and in batch
- The client reads the taxpayer identifier (NIP) from context.Context.
- Rationale:
- Consistency: a single, implicit source of truth for the current processing scope.
- Propagation: NIP travels with the request lifetime across layers without changing function signatures.
- Safety: avoids accidental mix-ups when multiple NIPs may be processed concurrently.
How it works
Set NIP into context once, near the request boundary:
ctx := ksef.Context(ctx, nipString)Components that require NIP retrieve it from context:
nip, ok := ksef.NipFromContext(ctx)Authorization flows (e.g., AuthWithToken) expect NIP to be present in the provided context. If missing, an error is returned.
Guidelines
- Always derive child contexts from the NIP-bearing parent (use the same ctx for subsequent calls).
- Do not pass NIP as a separate function parameter; rely on context for clarity and consistency.
- Validate NIP before injecting it into context if your application requires strict input checks.
- When spawning goroutines or timeouts, carry the same context forward (e.g., context.WithTimeout(ctx, ...)) so NIP remains available.
Example
At startup or per request:
ctx := context.Background()
ctx = ksef.Context(ctx, "")Use ctx for all API calls that require NIP
TokenProvider is a simple in-memory cache for KSeF tokens. It is thread-safe and can be used concurrently from multiple goroutines. In the next implementation, locks will be contextual — currently, the mutex is locked regardless of the NIP
EncryptionService is responsible for:
- fetching and caching KSeF public certificates,
- encryption for two distinct usages:
- KsefTokenEncryption (auth token + timestamp),
- SymmetricKeyEncryption (encrypting the AES key used for invoices),
- optional initialization without contacting the API (when you already have certificates/keys).
Keys are cached separately for each usage and automatically refreshed before expiration using a safety margin (refreshSkew).
Key points:
- Two independent caches: tokenPub (KsefTokenEncryption) and symKeyPub (SymmetricKeyEncryption).
- Automatic on-demand fetch from the API only when a key is missing or close to expiration.
- ForceRefresh() refreshes both caches (does not return keys).
- Optional preload of certificates/keys in the constructor to avoid API calls.
Standard (keys fetched on demand from the API):
// Go
env := ksef.Test
httpClient := &http.Client{ Timeout: 15 * time.Second }
enc, err := cipher.NewEncryptionService(env, httpClient)
if err != nil {
// handle error
}No API calls — preload with existing certs or keys
// Go
enc, err := cipher.NewEncryptionService(
env,
httpClient,
cipher.WithPreloadedKeys(cipher.PreloadedKeys{
// Option A: certificates in DER Base64 form
TokenCertBase64: "<base64-der-token>",
SymmetricCertBase64: "<base64-der-symmetric>",
// Option B: ready *rsa.PublicKey instances
// TokenRSAPub: tokenPub,
// SymmetricRSAPub: symPub,
// Optional validity dates (if omitted, they’ll be read from certs)
// TokenValidTo: time.Time{},
// SymmetricValidTo: time.Time{},
}),
)
if err != nil {
// handle error
}Encryption
Encrypt KSeF token (token + timestamp in ms), RSA-OAEP(SHA-256)
encryptedTokenBytes, err := enc.EncryptKsefToken(ctx, token, challenge.Timestamp)
if err != nil {
// handle error
}Encrypt the invoice symmetric key (Usage=SymmetricKeyEncryption)
encryptedSymKey, err := enc.EncryptSymmetricKey(ctx, aesKey)If a required key is missing or expired, EncryptionService will fetch/refresh the proper certificate and update its cache automatically.
if err := enc.ForceRefresh(ctx); err != nil {
// handle error
}After ForceRefresh, use the regular methods (EncryptKsefToken, EncryptSymmetricKey, or GetPublicKeyFor). ForceRefresh itself does not return keys.
- RSA-OAEP with SHA-256 is used for all RSA encryptions.
- Each key usage has its own key and validity tracked independently.
- refreshSkew is a safety margin checked on-demand: when a key is requested and its ValidTo − now ≤ refreshSkew (default 2 minutes), the service refreshes it; there is no periodic timer.
- GetPublicKeyFor(ctx, usage) returns the current key for the given usage and will fetch/cache it if needed.
package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/alapierre/go-ksef-client/ksef"
"github.com/alapierre/go-ksef-client/ksef/util"
"github.com/sirupsen/logrus"
)
func main() {
logrus.SetLevel(logrus.DebugLevel)
nip := util.GetEnvOrFailed("KSEF_NIP")
token := util.GetEnvOrFailed("KSEF_TOKEN")
httpClient := &http.Client{
Timeout: 15 * time.Second,
}
env := ksef.Test
authFacade, err := ksef.NewAuthFacade(env, httpClient)
if err != nil {
panic(err)
}
encryptor, err := ksef.NewEncryptionService(env, httpClient)
if err != nil {
panic(err)
}
ctx := context.Background()
ctx = ksef.Context(ctx, nip)
tokens, err := ksef.WithKsefToken(ctx, authFacade, encryptor, token)
if err != nil {
panic(err)
}
fmt.Println(tokens.AccessToken.Token)
fmt.Println(tokens.RefreshToken.Token)
refreshToken, err := authFacade.RefreshToken(ctx, tokens.RefreshToken.Token)
if err != nil {
panic(err)
}
fmt.Println(refreshToken.GetToken())
fmt.Println("Refreshed")
}package main
import (
"context"
"fmt"
"net/http"
"time"
"github.com/alapierre/go-ksef-client/ksef"
"github.com/alapierre/go-ksef-client/ksef/api"
"github.com/alapierre/go-ksef-client/ksef/util"
"github.com/sirupsen/logrus"
)
func openSession() {
logrus.SetLevel(logrus.DebugLevel)
nip := util.GetEnvOrFailed("KSEF_NIP")
token := util.GetEnvOrFailed("KSEF_TOKEN")
buer := util.GetEnvOrFailed("KSEF_BUYER_NIP")
httpClient := &http.Client{
Timeout: 15 * time.Second,
}
env := ksef.Test
authFacade, err := ksef.NewAuthFacade(env, httpClient)
if err != nil {
panic(err)
}
encryptor, err := ksef.NewEncryptionService(env, httpClient)
if err != nil {
panic(err)
}
ctx := context.Background()
ctx = ksef.Context(ctx, nip)
provider := ksef.NewTokenProvider(authFacade, func(ctx context.Context) (*api.AuthenticationTokensResponse, error) {
return ksef.WithKsefToken(ctx, authFacade, encryptor, token)
})
client, err := ksef.NewClient(env, httpClient, provider)
form := api.FormCode{
SystemCode: "FA (3)",
SchemaVersion: "1-0E",
Value: "FA",
}
key, err := ksef.GenerateRandom256BitsKey()
iv, err := ksef.GenerateRandom16BytesIv()
encryptedKey, err := encryptor.EncryptSymmetricKey(ctx, key)
enc := api.EncryptionInfo{
EncryptedSymmetricKey: encryptedKey,
InitializationVector: iv,
}
session, err := client.OpenInteractiveSession(ctx, form, enc)
if err != nil {
panic(err)
}
fmt.Println(session)
// send invoices
invoice, err := util.ReplacePlaceholdersInXML("../invoice_fa_3_type.xml", map[string]any{
"NIP": nip,
"ISSUE_DATE": time.Now(),
"BUYER_NIP": buer,
})
if err != nil {
panic(err)
}
ir, err := client.SendInvoice(ctx, string(session.ReferenceNumber), api.OptBool{}, invoice, key, iv)
if err != nil {
panic(err)
}
fmt.Println(ir)
}Cause by ogen issue: ogen-go/ogen#1570 validation of SHA-256 Base64 string is not working corectly. So in the generated code, the validation is commented out. Alternative is to change the following definition in the openapi.json:
from:
"Sha256HashBase64": {
"maxLength": 44,
"minLength": 44,
"type": "string",
"description": "SHA-256 w Base64.",
"format": "byte"
},to:
"Sha256HashBase64": {
"maxLength": 32,
"minLength": 32,
"type": "string",
"description": "SHA-256 w Base64.",
"format": "byte"
},Currently KseF OpenAPI does not define Too Many Requests (429) responses for any endpoints.
So the client does not handle them — ogen does not generate the code to get Retry-After response header.
Issue is tracked here: CIRFMF/ksef-docs#347
Workaround: manually add Too Many Requests 429 responses in OpenAPI spec and regenerate the client.
{
"responses": {
"429": {
"description": "Przekroczono limit żądań",
"headers": {
"Retry-After": {
"description": "ilość sekund na które założona jest blokada",
"schema": {
"type": "integer"
}
}
}
}
}
}The TokenProvider component is responsible for providing and transparently refreshing KSeF access tokens. It implements api.SecuritySource and is used by the generated client to attach Authorization: Bearer headers to all protected requests.
The provider is designed with the following goals:
-
Correctness under concurrency
Multiple goroutines may request tokens for the same or different NIPs at the same time.TokenProvideruses an internal cache protected by a mutex to ensure:- exactly one full authentication per NIP when tokens are missing or expired,
- serialized refresh of access tokens per NIP,
- race-free reads from the cache (verified with
go test -race).
-
Steady‑state performance
In typical usage, most calls should reuse a cached access token that is still valid. This “fast path” avoids network calls and does not allocate memory on the heap. -
Simplicity vs. complexity
The current implementation uses a single mutex and a per‑NIP cache. Given the expected load (usually 1–2, up to ~40 NIPs per application instance), more complex patterns (like per‑NIP locks orsingleflightgroups) were evaluated and considered unnecessary for now. The benchmarks below confirm that the cost of the provider itself is negligible compared to KSeF network latency.
Benchmarks focus on the Bearer method of TokenProvider in several scenarios:
-
Sequential, warm cache
Multiple calls for the same NIP with tokens already cached and valid.
This measures the absolute overhead of the provider in the happy‑path case. -
Parallel, same NIP, warm cache
Many goroutines requesting a token for the same NIP simultaneously, with tokens already cached.
This stresses the locking strategy and measures contention on the mutex. -
Parallel, many NIPs, cold cache
Many goroutines requesting tokens for many different NIPs when the cache is empty.
This simulates the worst case (initial warm‑up), where each NIP requires a full authentication. The benchmark is dominated by the simulated authentication delay; it is useful mainly to estimate upper bounds and allocations during warm‑up, not to compare micro‑optimizations. -
Parallel, many NIPs, warm cache
Many goroutines requesting tokens for multiple NIPs after the cache has been pre‑filled.
This is close to real‑world steady‑state with multiple tenants and concurrent traffic.
Go 1.25
go test ./ksef -bench 'BenchmarkTokenProvider_' -benchmemgoos: linux
goarch: amd64
pkg: github.com/alapierre/go-ksef-client/ksef
cpu: Intel(R) Core(TM) i9-9940X CPU @ 3.30GHz
BenchmarkTokenProvider_Sequential-28 13828598 73.83 ns/op 0 B/op 0 allocs/op
BenchmarkTokenProvider_ParallelSameNip-28 2263152 516.3 ns/op 0 B/op 0 allocs/op
BenchmarkTokenProvider_ParallelManyNips-28 507884 2033 ns/op 64 B/op 2 allocs/op
BenchmarkTokenProvider_ParallelManyNipsWarmCache-28 1261300 970.5 ns/op 64 B/op 2 allocs/op
PASS
ok github.com/alapierre/go-ksef-client/ksef 21.349s