API structure generated by ogen-go with AuthFacade, EncryptionService, Client and in memory TokenProvider.
This is not a full-featured SDK, but a production-ready, battle-tested client for the most critical KSeF operations, especially authentication and signing workflows.
This project is actively used in production environments (70+ commercial deployments) and is considered stable for its core use cases.
The current implementation focuses primarily on authentication and QR code generation for KSeF 2.0:
- Authentication using:
- KSeF Token
- KSeF Certificate
- QR code (one and two) generation compliant with KSeF requirements
Additionally, thanks to integration with github.com/alapierre/godss, the client supports:
- Qualified electronic signature
- Qualified electronic seal
These features are fully production-proven and widely used in real-world integrations.
At this stage, the library does not aim to provide full coverage of the KSeF API.
- Many API operations are not yet exposed through high-level abstractions (facades)
- The focus has been on reliability and correctness of critical paths rather than breadth of features
Currently supported (beyond authentication):
- Opening interactive sessions
- Sending invoices in interactive mode
- Closing interactive sessions
- Checking invoice processing status in a session
- Querying invoice metadata
- Downloading invoices by KSeF number
- Batch invoice submission
Other areas of the KSeF API may be added gradually based on demand and real-world usage.
- 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
TokenProvider can notify the caller when tokens are updated. This is useful when the application wants to persist the newest access token or refresh token in a database, distributed cache, or other durable storage.
provider := ksef.NewTokenProvider(
authFacade,
func(ctx context.Context) (*api.AuthenticationTokensResponse, error) {
return ksef.WithKsefToken(ctx, authFacade, encryptor, token)
},
ksef.WithTokenUpdateCallback(func(ctx context.Context, update ksef.TokenUpdate) error {
// Save update.AccessToken and update.RefreshToken for update.NIP.
// update.AccessTokenChanged/update.RefreshTokenChanged show which value changed.
return nil
}),
)If the callback returns an error, Bearer returns that error to the caller. The callback is invoked after TokenProvider updates its in-memory cache.
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), each with its publicKeyId.
- 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.
KSeF now can publish more than one valid key at the same time. A new certificate can mean either:
- re-certification: the certificate changes, but the public key and publicKeyId stay the same,
- key rotation: a new public key is published with a new publicKeyId.
For requests where KSeF must know which public key was used for encryption, the request should include publicKeyId.
This affects:
POST /auth/ksef-token,POST /sessions/online,POST /sessions/batch.
/invoices/exports also requires this selector in KSeF, but this client does not currently expose invoice exports.
The library now selects the currently valid certificate for each usage. If more than one valid certificate exists for a usage,
it uses the one with the latest validFrom. When KSeF returns error 21470 ("unknown or withdrawn key"),
the high-level helpers force-refresh public certificates and retry the operation once with the newly selected key.
Recommended APIs:
- Use
WithKsefTokenorWithKsefTokenResultfor token authentication. - Use
OpenInteractiveSession(ctx, form, key, iv)for online sessions. - Use
OpenBatchSession(ctx, form, key, iv, offline, batchFile)for batch sessions. - Let
Clientuse its defaultEncryptionService, or pass a shared/custom one withWithEncryptionService. - Use
BuildEncryptionInfowhen you need to buildapi.EncryptionInfoyourself in custom code. - Use
EncryptKsefTokenWithKeyID,EncryptSymmetricKeyWithKeyID, orGetPublicKeyWithIDForonly for custom low-level integrations.
Lower-level APIs kept for custom integrations:
EncryptKsefToken/EncryptSymmetricKey: return encrypted bytes only and are deprecated.EncryptKsefTokenWithKeyID/EncryptSymmetricKeyWithKeyID: return encrypted bytes and publicKeyId.GetPublicKeyFor: returns only the RSA key, deprecated.GetPublicKeyWithIDFor: returns the RSA key and publicKeyId.AuthWithToken: accepts optional publicKeyId; direct users should pass it and handle error21470.
This release changes the public Client session API. Session-opening methods now accept the raw AES key and IV instead of a prebuilt api.EncryptionInfo, because the client must be able to re-encrypt the symmetric key after refreshing KSeF public certificates.
Opening an online session before this release:
encryptedKey, err := encryptor.EncryptSymmetricKey(ctx, key)
if err != nil {
// handle error
}
enc := api.EncryptionInfo{
EncryptedSymmetricKey: encryptedKey,
InitializationVector: iv,
}
session, err := client.OpenInteractiveSession(ctx, form, enc)Recommended:
session, err := client.OpenInteractiveSession(ctx, form, key, iv)Client.OpenInteractiveSession now:
- encrypts the symmetric key,
- fills
EncryptionInfo.PublicKeyId, - calls
POST /sessions/online, - on KSeF error
21470, refreshes public certificates and retries once.
By default, NewClient creates an EncryptionService internally:
client, err := ksef.NewClient(env, httpClient, provider)If you already have an encryptor, for example because token authentication uses the same instance or you preload keys, pass it to the client:
encryptor, err := ksef.NewEncryptionService(env, httpClient)
if err != nil {
// handle error
}
client, err := ksef.NewClient(
env,
httpClient,
provider,
ksef.WithEncryptionService(encryptor),
)Opening a batch session before this release:
encryptedKey, err := encryptor.EncryptSymmetricKey(ctx, batchResult.AESKey)
if err != nil {
// handle error
}
enc := api.EncryptionInfo{
EncryptedSymmetricKey: encryptedKey,
InitializationVector: batchResult.IV,
}
openResp, err := client.OpenBatchSession(ctx, form, enc, offline, batchFile)Recommended:
openResp, err := client.OpenBatchSession(
ctx,
form,
batchResult.AESKey,
batchResult.IV,
offline,
batchFile,
)Token authentication with WithKsefToken already uses the recommended flow:
tokens, err := ksef.WithKsefToken(ctx, authFacade, encryptor, token)If you use low-level token authentication directly, migrate from encrypted bytes only:
encryptedToken, err := encryptor.EncryptKsefTokenWithKeyID(ctx, token, challenge.Timestamp)
if err != nil {
// handle error
}
initResp, err := authFacade.AuthWithToken(
ctx,
challenge.Challenge,
encryptedToken.Data,
encryptedToken.PublicKeyID,
)Standard (keys fetched on demand from the API):
// Go
env := ksef.Test
httpClient := &http.Client{ Timeout: 15 * time.Second }
enc, err := ksef.NewEncryptionService(env, httpClient)
if err != nil {
// handle error
}No API calls — preload with existing certs or keys
// Go
enc, err := ksef.NewEncryptionService(
env,
httpClient,
ksef.WithPreloadedKeys(ksef.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,
// Required when preloading keys for requests that need publicKeyId.
// TokenPublicKeyID: tokenPublicKeyID,
// SymmetricPublicKeyID: symPublicKeyID,
// 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)
encryptedToken, err := enc.EncryptKsefTokenWithKeyID(ctx, token, challenge.Timestamp)
if err != nil {
// handle error
}
// encryptedToken.Data is the encrypted payload.
// encryptedToken.PublicKeyID must be sent as publicKeyId.Encrypt the invoice symmetric key (Usage=SymmetricKeyEncryption)
encryptionInfo, err := enc.BuildEncryptionInfo(ctx, aesKey, iv)
if err != nil {
// handle error
}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 (EncryptKsefTokenWithKeyID, BuildEncryptionInfo, or GetPublicKeyWithIDFor). 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.
- GetPublicKeyWithIDFor(ctx, usage) returns the current key and publicKeyId for the given usage and will fetch/cache it if needed.
- If KSeF returns error 21470, token authentication and the client session methods force-refresh public certificates and retry once with the new key.
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")
if err := authFacade.CloseAuthSession(ctx, refreshToken.GetToken()); err != nil {
panic(err)
}
}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, ksef.WithEncryptionService(encryptor))
form := api.FormCode{
SystemCode: "FA (3)",
SchemaVersion: "1-0E",
Value: "FA",
}
key, err := ksef.GenerateRandom256BitsKey()
iv, err := ksef.GenerateRandom16BytesIv()
session, err := client.OpenInteractiveSession(ctx, form, key, iv)
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)
statuses, err := client.SessionInvoices(ctx, string(session.ReferenceNumber), api.OptString{}, api.NewOptInt32(10))
if err != nil {
panic(err)
}
for _, invoiceStatus := range statuses.GetInvoices() {
fmt.Println(invoiceStatus.GetReferenceNumber(), invoiceStatus.GetStatus().Code)
}
closedRef, err := client.CloseInteractiveSession(ctx, string(session.ReferenceNumber))
if err != nil {
panic(err)
}
fmt.Println(closedRef)
}Use QueryInvoicesMetadata to search invoice metadata and GetInvoiceByKsefNumber to download the invoice XML.
filters := api.InvoiceQueryFilters{
SubjectType: api.InvoiceQuerySubjectTypeSubject1,
DateRange: api.InvoiceQueryDateRange{
DateType: api.InvoiceQueryDateTypeIssue,
From: time.Now().AddDate(0, -1, 0),
To: api.NewOptNilDateTime(time.Now()),
},
}
metadata, err := client.QueryInvoicesMetadata(ctx, filters, api.InvoicesQueryMetadataPostParams{
SortOrder: api.NewOptSortOrder(api.SortOrderDesc),
PageOffset: api.NewOptInt32(0),
PageSize: api.NewOptInt32(50),
})
if err != nil {
panic(err)
}
for _, invoice := range metadata.GetInvoices() {
fmt.Println(invoice.GetKsefNumber())
}
downloaded, err := client.GetInvoiceByKsefNumber(ctx, "1234567890-20260101-ABCDEF123456-12")
if err != nil {
panic(err)
}
xmlReader := downloaded.GetResponse()
hash := downloaded.GetXMsMetaHash()
fmt.Println(hash)
_ = xmlReaderInvoice export is asynchronous. Start the export with filters and an AES key/IV, poll the status until KSeF returns status code 200, then download and decrypt the package parts into any io.Writer.
The downloaded output is one complete ZIP stream assembled from decrypted package parts. The example below writes it to a file, but the writer can also be a buffer, pipe, object-storage writer, HTTP response writer, etc.
filters := api.InvoiceQueryFilters{
SubjectType: api.InvoiceQuerySubjectTypeSubject1,
DateRange: api.InvoiceQueryDateRange{
DateType: api.InvoiceQueryDateTypePermanentStorage,
From: time.Now().AddDate(0, 0, -7),
To: api.NewOptNilDateTime(time.Now()),
},
}
export, err := client.StartInvoiceExportWithGeneratedKey(ctx, filters, api.OptBool{})
if err != nil {
panic(err)
}
var status *api.InvoiceExportStatusResponse
for {
status, err = client.InvoiceExportStatus(ctx, export.ReferenceNumber)
if err != nil {
panic(err)
}
if status.GetStatus().Code == 200 {
break
}
if status.GetStatus().Code != 100 {
panic(fmt.Errorf("invoice export failed with status %d: %s", status.GetStatus().Code, status.GetStatus().Description))
}
time.Sleep(5 * time.Second)
}
out, err := os.Create("ksef-invoices-export.zip")
if err != nil {
panic(err)
}
defer out.Close()
result, err := client.DownloadInvoiceExport(ctx, status, export.Key, export.IV, out)
if err != nil {
panic(err)
}
fmt.Println(result.InvoiceCount, result.BytesWritten, result.IsTruncated)If you need to control the AES key and IV yourself, use StartInvoiceExport(ctx, filters, onlyMetadata, key, iv). Persist export.Key and export.IV together with export.ReferenceNumber if the export will be downloaded later.
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"
},KSeF OpenAPI specification now defines support for HTTP 429 (Too Many Requests) responses.
However, the current version of this client does not yet handle 429 responses at the facade level:
ogen-generated low-level client is capable of receiving 429 responses but higher-level abstractions (facades) do not yet implement automatic retries, Retry-After header handling and backoff strategies.
As a result, handling of rate limiting must currently be implemented on the application side.
Support for proper 429 handling is planned for future releases.
Reference: CIRFMF/ksef-api#347
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
Because of how ogen handles size validation for string fields with the byte format, the maxLength and minLength constraints must be removed from the Sha256HashBase64 type or set to a value of 31. Ogen validates the length of the underlying byte array rather than the length of the Base64-encoded string.
"Sha256HashBase64": {
"type": "string",
"description": "SHA-256 w Base64.",
"format": "byte"
},Prevent failure when parsing JSON with unknown fields:
replace:
return errors.Errorf("unexpected field %q", k)
to:
return d.Skip()
on generated parser oas_json_gen.go