Fitko.FitConnect.Client
4.0.0-rc.1.2
dotnet add package Fitko.FitConnect.Client --version 4.0.0-rc.1.2
NuGet\Install-Package Fitko.FitConnect.Client -Version 4.0.0-rc.1.2
<PackageReference Include="Fitko.FitConnect.Client" Version="4.0.0-rc.1.2" />
<PackageVersion Include="Fitko.FitConnect.Client" Version="4.0.0-rc.1.2" />
<PackageReference Include="Fitko.FitConnect.Client" />
paket add Fitko.FitConnect.Client --version 4.0.0-rc.1.2
#r "nuget: Fitko.FitConnect.Client, 4.0.0-rc.1.2"
#:package Fitko.FitConnect.Client@4.0.0-rc.1.2
#addin nuget:?package=Fitko.FitConnect.Client&version=4.0.0-rc.1.2&prerelease
#tool nuget:?package=Fitko.FitConnect.Client&version=4.0.0-rc.1.2&prerelease
FIT-Connect SDK — Client Module
High-level .NET client for the FIT-Connect platform: sending submissions and replies, receiving and decrypting them,
looking up routes, and managing destinations. This is the typical entry point for an integrator — it hides the JWE/JWS,
REST and protocol details behind a fluent builder API and a single IFitConnectClient resolved from the DI container.
If you only need crypto, hashing, or validation primitives, depend on
Fitko.FitConnect.Core directly. For malware scanning, add
Fitko.FitConnect.VirusScanning. For ZBP messages, add
Fitko.FitConnect.Zbp.
Install
<PackageReference Include="Fitko.FitConnect.Client" />
Quick Start
// Register services
services.AddFitConnect(); // reads "FitConnect" section from IConfiguration
// Resolve client
var client = serviceProvider.GetRequiredService<IFitConnectClient>();
// Pick the role-scoped facade for the destination you're acting as:
// - AsOrganisation() for an A/B-destination (Verwaltung/Wirtschaft) — send/receive submissions,
// send replies. Has no method to receive replies.
// - AsOnlineService() for a C-destination (Onlinedienst) — send submissions, send/receive replies.
// Has no method to receive submissions.
// A method the caller can't legitimately use for their role simply isn't on the facade's type.
var organisation = client.AsOrganisation();
Legacy flat access:
client.SubmissionService(typedISubmissionService) remains available and exposes the full send/receive surface regardless of role. It is kept for backward compatibility, butAsOrganisation()/AsOnlineService()are the recommended entry points since they make illegal operations for your role a compile error rather than a runtime REST rejection.
Role verification: by default the SDK resolves the configured
DestinationId's type (A/B/C) once while the host starts, and asking for the facade of the wrong role then throwsFitConnectConfigurationExceptionnaming the correct one. ADestinationIdthat does not exist aborts host startup. Turn this off withClient.VerifyDestinationOnStartup = falsefor offline scenarios; the check needs anIHost(it runs as anIHostedService), so a bareServiceProviderskips it.
// Send a submission
var request = OutgoingSubmissionBuilder.Builder()
.WithDestinationId(destinationId)
.WithServiceType("urn:de:fim:leika:leistung:99400048079000", "Service Name")
.WithMetadataVersion(new Version(3, 0, 0))
.WithJsonData(jsonData, new Uri("https://schema.example.org/schema.json"))
.WithAttachment(Attachment.FromFile("document.pdf", "application/pdf"), shouldBeChunked: false)
.Build();
SentSubmission sent = await organisation.SendSubmission(request);
// Receive a submission
IncomingSubmission received = await organisation.FetchSubmission(
sent.SubmissionId, sent.CaseId);
string data = received.GetDataAsString();
List<Attachment> attachments = received.GetAttachments();
Registration
// From IConfiguration (default section: "FitConnect")
services.AddFitConnect();
services.AddFitConnect("MySection");
// Programmatic
services.AddFitConnect(config =>
{
config.Client = new ClientConfig
{
ClientId = "...",
ClientSecret = "...",
DestinationId = Guid.Parse("..."), // C-Destination (sender identity, required in v3)
DecryptionKeys = [privateKeyJson]
};
config.EnvironmentTag = "TEST";
});
Configuration
appsettings.json
{
"FitConnect": {
"Client": {
"ClientId": "<client-id>",
"ClientSecret": "<client-secret>",
"DestinationId": "<c-destination-uuid>",
"VerifyDestinationOnStartup": true,
"DecryptionKeys": [
"<RSA private JWK JSON>",
"<RSA private JWK JSON>"
]
},
"EnvironmentTag": "TEST",
"Http": {
"Timeouts": {
"Read": 30,
"Write": 30,
"Connection": 10
},
"Proxy": {
"Host": "",
"Port": 0
},
"Retry": {
"AllowRetries": true,
"RetryableStatusCodes": [408, 429, 500, 502, 503, 504],
"MaxRetryCount": 5,
"InitialDelayInMs": 500
}
},
"Attachments": {
"BaseDirectory": "/var/lib/fit-connect",
"ChunkAllAttachments": true,
"ChunkSizeInMB": 50
},
"Validation": {
"Metadata": true,
"Data": true,
"Attachments": true
}
}
}
EnvironmentTag accepts: TEST | STAGE | PROD | LOCAL | CI | CUSTOM. DecryptionKeys takes an ordered
list — the first key is primary, subsequent keys enable zero-downtime rotation. BaseDirectory defaults to the system
temp path when omitted.
Custom environment
services.AddFitConnect(config =>
{
config.EnvironmentTag = "CUSTOM";
config.CustomEnvironment = new CustomEnvironmentUrls
{
TokenUrl = "https://my-auth-server/token",
SubmissionUrl = "https://my-api-host/submission-api",
RoutingUrl = "https://my-routing-server",
DestinationUrl = "https://my-api-host/destination-api"
};
});
Sending submissions
Sending is available on both role-scoped facades — organisation.SendSubmission(...) (A/B → A/B)
and onlineService.SendSubmission(...) (C → A/B) — the examples below use organisation.
// JSON data + attachment
var request = OutgoingSubmissionBuilder.Builder()
.WithDestinationId(destinationId)
.WithServiceType(leikaKey, serviceName)
.WithMetadataVersion(new Version(3, 0, 0))
.WithJsonData(jsonData, schemaUri)
.WithAttachment(Attachment.FromFile("evidence.pdf", "application/pdf"), shouldBeChunked: false)
.Build();
SentSubmission sent = await organisation.SendSubmission(request);
Console.WriteLine($"submissionId={sent.SubmissionId}, caseId={sent.CaseId}");
// XML data
var request = OutgoingSubmissionBuilder.Builder()
.WithDestinationId(destinationId)
.WithServiceType(leikaKey, serviceName)
.WithMetadataVersion(new Version(3, 0, 0))
.WithXmlData(xmlString, schemaUri)
.Build();
// With reply channel (so the receiver can send a reply back).
// In v3 the FIT-Connect reply channel carries the online service's own C-Destination id
// that replies should be sent to, plus the schemas it can receive replies in.
var request = OutgoingSubmissionBuilder.Builder()
...
.WithJsonData(data, schemaUri)
.WithReplyChannel(ReplyChannel.OfFitConnect(replyPublicKey, myCDestinationId))
.Build();
Receiving submissions
// Fetch a specific submission — organisation (A/B) only, use client.AsOrganisation().
IncomingSubmission received = await organisation.FetchSubmission(submissionId, caseId);
string data = received.GetDataAsString();
// Inspect the receive report and decide: accept a clean transfer, reject a broken one.
if (received.Acceptable())
{
await organisation.AcceptSubmission(received);
}
else
{
Console.WriteLine(received.Report.Describe()); // human-readable diagnosis
await organisation.RejectSubmission(received, received.Report.AsProblems().ToList());
}
// Fetch all available submissions from a destination — each carries its own report
List<IncomingSubmission> all = await organisation
.FetchAvailableSubmissionsFromDestination(destinationId);
Report, not exceptions:
FetchSubmissionno longer throws on a validation finding. Everything the receive pipeline finds (schema violations, hash/auth-tag mismatches, virus-scan findings, ...) is collected intoreceived.Report(aReceiveReport). Inspectreceived.Acceptable()/received.Reportto decide what to do; only hard technical failures (missing keys, failed decryption, transport errors) still throw.AcceptSubmissionrefuses (throwsSubmissionNotAcceptableException) while the report still has errors — warnings never block acceptance.Report.AsProblems()yields the problems ready to hand toRejectSubmission. The SDK does not automatically send a reject event. To poll for the final processing status (Accepted / Rejected) after sending, useGetSubmissionState(sentSubmission).
Replies
// Send a reply (receiver-to-sender) — organisation (A/B) only.
var replyRequest = ReplyRequestBuilder
.ForCase(caseId)
.EncryptWith(senderPublicKey)
.WithMetadataVersion(new Version(3, 0, 0))
.WithJsonData(replyJson, schemaUri)
.WithAttachment(Attachment.FromBytes(pdfBytes, "application/pdf"))
.Build();
SendReplyResponse sentReply = await organisation.SendReply(replyRequest);
// Answering a received submission — extracts the case ID and the FIT-Connect reply-channel
// encryption key from the received submission automatically. Throws FitConnectReplyException
// if the sender did not offer a FIT-Connect reply channel.
var replyRequest = ReplyRequestBuilder
.ForSubmission(receivedSubmission)
.WithMetadataVersion(new Version(3, 0, 0))
.WithJsonData(replyJson, schemaUri)
.Build();
SendReplyResponse sentReply = await organisation.SendReply(replyRequest);
// Fetch and decrypt a reply (sender-to-receiver) — online service (C) only, use client.AsOnlineService().
var onlineService = client.AsOnlineService();
IncomingSubmission reply = await onlineService
.FetchSpecificReply(replyId, privateDecryptionKeyJson);
await onlineService.AcceptReply(reply);
// or:
await onlineService.RejectReply(reply, problems);
Reply-channel keys
A reply-channel key belongs to one case: the public half goes out with the submission that opens the case, and the
private half decrypts the reply that comes back. Storing it in between is the application's job (a vault, an HSM, a
column on the case), so IReplyKeys is how that store is handed to the SDK — after which it resolves the right key per
reply itself:
using Fitko.FitConnect.Client.Models.Crypto;
// At send time, keep the private half against the case just opened.
var sent = await onlineService.SendSubmission(submissionRequest);
await vault.Store(sent.CaseId, privateJwk);
// Later: no need to know which key this particular reply needs.
IncomingSubmission reply = await onlineService.FetchSpecificReply(
replyId, ReplyKeys.FromLookup(vault.Find));
ReplyKeys.Of(dictionary) wraps an in-memory map (tests, short-lived processes), ReplyKeys.FromLookup(func) adapts an
existing store, and ReplyKeys.None() states explicitly that a destination only ever sends. If the store holds no key
for the reply's case, a FitConnectReplyException names both the case and the reply.
Message status
GetSubmissionState / GetReplyState read the latest event-log entry. Event-log entries are signed rather than
encrypted, so a status check needs no decryption key — including for messages still awaiting pickup:
// Outgoing: poll for the final state after sending.
EventState state = await organisation.GetSubmissionState(sentSubmission);
EventState replyState = await organisation.GetReplyState(sentReply);
// Incoming, still queued: check state without fetching/decrypting the message.
foreach (var waiting in await onlineService.FetchAvailableReplies() ?? [])
Console.WriteLine(await onlineService.GetReplyState(waiting));
All four throw FitConnectMappingException when the event log is empty — "no status yet" is deliberately not conflated
with "no status".
Attachments
// From file path
Attachment file = Attachment.FromFile("document.pdf", "application/pdf");
// From bytes (in-memory)
Attachment bytes = Attachment.FromBytes(pdfBytes, "application/pdf");
// From a stream
Attachment stream = Attachment.FromStream(
inputStream, "application/xml", "data.xml", shouldBeChunked: false);
// From a repeatable stream supplier (e.g. blob storage — reopened on retry)
Attachment blob = Attachment.FromStreamSupplier(
() => blobStorage.Open(documentId), "application/pdf", "document.pdf");
Routing
Destination discovery goes through client.Directory:
// LeiKa search, scoped to exactly one area (AGS / ARS / AreaId — enforced by AreaFilter)
IReadOnlyList<Route> routes = await client.Directory.FindByService(
leikaKey: "urn:de:fim:leika:leistung:99400048079000",
area: AreaFilter.Ars("081190090090"));
// Process-message search (returns full Destination objects)
MultipleDestinations? matches = await client.Directory.FindByProcessMessage(processModelId, messageId, "DE09");
AreaList? areas = await client.Directory.FindAreas(["Leipzig"]);
// Active encryption key of a destination (the key its EncryptionKid points at)
ApiJwk key = await client.Directory.GetActiveEncryptionKey(destinationId);
Destination management
IDestinationApiClient destinations = client.DestinationApiService;
Destination dest = await destinations.GetDestination(destinationId);
MultipleDestinations list = await destinations.ListDestinations(offset: 0, limit: 100);
Destination created = await destinations.CreateDestination(createDestination);
await destinations.AddKey(destinationId, publicJwk);
DestinationAttachmentLimit limit = await destinations.GetDestinationAttachmentLimit(destinationId);
Callbacks
A destination can carry a callback URL and secret, so the delivery service notifies you when new messages are waiting
instead of you polling. The callback endpoint is publicly reachable, so verify every incoming callback before acting on
it, using CallbackValidationUtil from Fitko.FitConnect.Core:
using Fitko.FitConnect.Core.Callbacks;
// Pass the raw request body: re-serializing or trimming it changes the bytes and the HMAC will not match.
var result = CallbackValidationUtil.ValidateCallback(
request.Headers[CallbackHeaders.Authentication],
request.Headers[CallbackHeaders.Timestamp],
rawBodyBytes,
callbackSecret);
if (!result.IsValid) return Results.Unauthorized();
The body only announces what is available (NewSubmissionsCallback, NewRepliesCallback, NewEventsCallback) — fetch
the actual messages through the authenticated API afterwards.
Build and Test
dotnet build Fitko.FitConnect.Client
dotnet test Fitko.FitConnect.Client.Test
Further reading
License
Source code is licensed under the EUPL.
Rechtlicher Hinweis: Dieses Software Development Kit (SDK) ist dazu bestimmt, die Anbindung einer Software an die FIT-Connect-Infrastruktur zu ermöglichen. Hierfür kann das SDK in die anzubindende Software integriert werden. Erfolgt die Integration des SDK in unveränderter Form, liegt keine Bearbeitung im Sinne der EUPL bzw. des deutschen Urheberrechts vor. Die Art und Weise der Verlinkung des SDK führt insbesondere nicht zur Schaffung eines abgeleiteten Werkes. Die unveränderte Übernahme des SDK in eine anzubindende Software führt damit nicht dazu, dass die anzubindende Software unter den Bedingungen der EUPL zu lizenzieren ist. Für die Weitergabe des SDK selbst – in unveränderter oder bearbeiteter Form, als Quellcode oder ausführbares Programm – gelten die Lizenzbedingungen der EUPL in unveränderter Weise.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Fitko.FitConnect.Core (>= 4.0.0-rc.1.2)
- Fitko.FitConnect.VirusScanning (>= 4.0.0-rc.1.2)
- JsonSchema.Net (>= 9.2.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.7)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.7)
- Microsoft.Extensions.Configuration.Json (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.7)
- Microsoft.Extensions.Http (>= 10.0.7)
- Microsoft.Extensions.Http.Resilience (>= 10.5.0)
- Microsoft.Extensions.Logging (>= 10.0.7)
- Microsoft.Extensions.Logging.Console (>= 10.0.7)
- Microsoft.Extensions.Options.DataAnnotations (>= 10.0.7)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.18.0)
- Microsoft.IdentityModel.Tokens (>= 8.18.0)
- Polly (>= 8.6.6)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.0-rc.1.2 | 70 | 9/15/2026 |