Spickzettel (Java)
Stand
Spickzettel als PDFTL;DR – Alle Kernbausteine des Java-SDKs auf einer Seite: Rollen, Adressieren, Senden, Empfangen, Antworten, Cases, Routing, Verwaltung, Krypto, Validierung, Konfiguration, Fehlerbehandlung, CLI und die häufigsten Fallstricke.
Diese Seite beschreibt das Objektmodell von 4.0.0-rc.1 (Branch main), wie es der aktuelle Java-SDK-Stand
tatsächlich verwendet: Organisation/OnlineService als Rollen, Participant/Addressing zur
Adressierung, OutgoingSubmission.to(...) als Builder. Die Rezeptseiten (siehe
Java & .NET SDK → Rezepte in der Seitenleiste) sind auf denselben Stand synchronisiert.
OrganisationA/B
- Empfängt Anträge und öffnet sie mit
DestinationKeys - Nimmt an oder weist zurück (
accept/reject) - Sendet Anträge an andere A/B-Destinations
- Sendet Antworten in Cases von C-Destinations
- Empfängt keine Antworten — das ist kein Bug (R3)
OnlineServiceC
- Sendet Anträge an A/B-Destinations
- Sendet auch vorverschlüsselt (
EncryptedOutgoingSubmission) - Empfängt Antworten und öffnet sie mit
ReplyKeys - Nimmt Antworten an oder weist sie zurück
- Empfängt keine Anträge — nur A/B sind Empfänger (R2)
Die vier Protokollregeln
- R1 Jede Partei ist eine Destination: UUID + Typ A/B/C.
- R2 Ein Antrag geht an eine Destination — nur A oder B.
- R3 Eine Antwort geht an einen Case, nie an eine Destination.
- R4 Schlüssel bestimmen, was man öffnen kann, nie was man senden darf.
Einstieg
Abhängigkeit
<dependency>
<groupId>dev.fitko.fitconnect</groupId>
<artifactId>sdk-client</artifactId>
<version>4.0.0-rc.1</version>
</dependency>
Mehrere Module? Einmal die BOM sdk-bom in dependencyManagement importieren, danach überall ohne
<version> deklarieren.
| Modul | Zweck |
|---|---|
sdk-client | High-Level-Client, alles Protokoll inklusive |
core | Krypto, HTTP, Schema-Validierung |
rest-clients | Rohe REST-Zugriffe, OAuth, API-Modelle |
virus-scanner | ICAP, ClamAV-Daemon oder clamscan |
zbp-client | Zentrales Bürgerpostfach (BundID) |
cli | fit-connect-cli.jar, nicht auf Maven Central |
SDK-Instanz erzeugen
// 1 — Builder (üblich)
FitConnectSdk sdk = FitConnectSdk.fromConfigBuilder()
.credentials("client-id", "client-secret")
.environment(FitConnectEnvironment.TEST)
.settings(SdkSettings.withDefaultSettings())
.build();
// 2 — aus YAML
FitConnectSdk sdk = FitConnectSdk.fromConfigYaml(Path.of("config.yml"));
Eine Credential-Paarung für Senden und Empfangen — die alte Trennung in senderConfig/subscriberConfig
(aus v3) gibt es nicht mehr.
Vier Wege ins SDK
Organisation amt = sdk.organisation(destId, keys); // A/B
Organisation amt = sdk.organisation(destId); // A/B, nur senden
OnlineService svc = sdk.onlineService(destId, replyKeys); // C
OnlineService svc = sdk.onlineService(destId); // C, nur senden
Directory dir = sdk.directory(); // Suche, unauthentifiziert
Management mgm = sdk.manage(); // Destinations + Anhänge
Die Destination, als die Sie handeln, steht nicht in der Konfiguration — sie gehört an die Aufrufstelle, zusammen mit ihrem Schlüsselmaterial.
Alle Methoden
| Methode | Rückgabe |
|---|---|
awaitingSubmissions() | List<SubmissionForPickup> |
awaitingSubmissions(offset, limit) | SubmissionsForPickup |
receive(SubmissionForPickup) | ReceivedSubmission |
receive(UUID submissionId) | ReceivedSubmission |
accept(received, Problem…) | void |
reject(received, problems) | void |
reject(waiting, problems) | void — ohne Entschlüsseln |
send(OutgoingSubmission) | SentSubmission |
send(OutgoingReply) | SentReply |
cases() | OrganisationCases |
Alle Methoden
| Methode | Rückgabe |
|---|---|
send(OutgoingSubmission) | SentSubmission |
send(EncryptedOutgoingSubmission) | SentSubmission |
awaitingReplies() | List<ReplyForPickup> |
awaitingReplies(offset, limit) | RepliesForPickup |
receive(ReplyForPickup) | ReceivedReply |
accept(received, Problem…) | void |
reject(received, problems) | void |
cases() | OnlineServiceCases |
Acht Mitglieder, keine Unter-Objekte: einen Antrag zu senden soll genau eine Zeile kosten.
Empfänger adressieren
Wer bekommt den Antrag?
// Genau eine Factory — beide Argumente Pflicht
Participant amt =
Participant.of(destinationId, addressing);
amt.destinationId(); // UUID
amt.addressing(); // Addressing, nie null
Nur für Anträge. Eine Antwort adressiert nie einen Participant — der Case ist die Adresse.
Leistung oder Prozessnachricht
// Nach Verwaltungsleistung (LeiKa/FIM)
Addressing.toService(serviceIdentifier, serviceName);
// Nach Prozessnachricht (Prozesskommunikation)
Addressing.toProcessMessage(processModelId, messageId);
Versiegelte Schnittstelle mit zwei Varianten: Addressing.ByService und Addressing.ByProcessMessage.
region() ist optional (ARS/AGS). Prozessadressierte Anträge brauchen einen Case: vorher
inCase(…), openingCase(…) oder openingCaseAt(…) setzen, sonst wirft build().
Zuständige Stelle suchen
// jede Suche unauthentifiziert
Directory dir = sdk.directory();
Participant p = dir.forService(serviceId, name, ars);
List<Participant> ps =
dir.allForService(serviceId, name, ars);
EncryptionKey k =
dir.activeEncryptionKeyOf(destinationId);
Die Suchergebnisse sind bereits fertig adressierte Participant-Objekte — direkt in
OutgoingSubmission.to(…) weiterreichbar.
Senden
Die Builder-Kette
SentSubmission sent = svc.send(
OutgoingSubmission.to(amt) // Participant — Pflicht
// — Case (optional bei Leistungs-, Pflicht bei Prozessadressierung) —
.inCase(existingCaseId) // bestehendem Case beitreten
// — Fachdaten — Pflicht, schaltet die optionalen Setter frei —
.setData(SubmissionData.json(json, schemaUri))
// — alles Weitere optional, Reihenfolge frei —
.addAttachment(Attachment.fromFile(pdf, "application/pdf"))
.setReplyChannel(ReplyChannels.fitConnect(replyPubKey, standards))
.setAuthor(author) // Metadaten 2.x/3.x
.setDataSets(dataSets) // Metadaten 2.x/3.x
.build());
sent.submissionId(); sent.caseId();
Der Typ wandert mit: to() liefert einen SubmissionDataStep, setData() einen
SubmissionOptionalPropertiesStep. Was gerade nicht erlaubt ist, ist gar nicht erst sichtbar.
Fachdaten verpacken
SubmissionData.json(String data, URI schemaUri);
SubmissionData.json(byte[] data, URI schemaUri);
SubmissionData.xml (String data, URI schemaUri);
SubmissionData.xml (byte[] data, URI schemaUri);
Ein Wert bündelt MIME-Type, Schema-URI und Bytes. Automatik: Daten über 13 MB werden transparent als Anhang platziert statt inline eingebettet.
Anhänge erzeugen
Attachment.fromBytes(bytes, "application/pdf");
Attachment.fromFile(
Path.of("bescheid.pdf"), "application/pdf");
Attachment.builder()
.fromFile(path)
// FORM | ATTACHMENT | REPORT | DATA
.purpose(Purpose.ATTACHMENT)
.shouldBeChunked(true) // gestückelt hochladen
.build();
Ohne fileName wird eine UUID vergeben, ohne purpose gilt ATTACHMENT.
Rückkanal anbieten
ReplyChannels.fitConnect(jwkOrEncryptionKey, processStandards);
ReplyChannels.email(address);
ReplyChannels.emailWithPgp(address, pgpPublicKey);
ReplyChannels.deMail(address);
ReplyChannels.elster(accountId, deliveryTicket, reference);
Nur fitConnect(…) macht eine Antwort über FIT-Connect möglich. Ohne ihn wirft
OutgoingReply.answering(received) — und der Antrag bleibt einseitig.
EncryptedOutgoingSubmission
// Für Architekturen, die im Browser/Frontend verschlüsseln:
// das SDK sieht die Klardaten nie.
SentSubmission sent = svc.send(
EncryptedOutgoingSubmission.to(amt)
.openingCase(serviceIdentifier, serviceName)
.setEncryptedData(jweCompact, Sha512Hash.ofHex(hex), schemaUri, MimeType.APPLICATION_JSON)
.addEncryptedAttachment(new EncryptedAttachmentMetadata(
attachmentId, jweContent, hash, "antrag.pdf",
"application/pdf", Purpose.ATTACHMENT, "Scan"))
.setReplyChannel(channel)
.build());
Gleiche optionale Setter wie beim normalen Antrag. Organisation hat diese Überladung bewusst
nicht: vorverschlüsseltes Senden ist ein Typ-C-Szenario.
Empfangen
Anträge abholen und bearbeiten
Organisation amt = sdk.organisation(MY_DEST_ID, keys);
for (SubmissionForPickup waiting : amt.awaitingSubmissions()) {
ReceivedSubmission received = amt.receive(waiting); // entschlüsselt + validiert
if (received.acceptable()) { // kurz für report().acceptable() — kein Auto-Reject mehr
amt.accept(received); // Quittung ins Event-Log
} else {
System.out.println(received.report().describe());
amt.reject(received, received.report().asProblems());
}
}
// Zusätzliche eigene Problems statt/neben dem Report:
amt.reject(received, List.of(new DataSchemaViolation("Feld X fehlt")));
// Zurückweisen ohne Entschlüsseln (z. B. Schlüssel fehlt)
amt.reject(waiting, List.of(new MetadataEncryptionKeyIssue("kid-2024-01")));
accept nimmt zusätzlich Probleme als Varargs entgegen — Annahme trotz Beanstandung ist damit
möglich, auch wenn acceptable() bereits true ist. Ruft man accept auf, während acceptable()
false liefert, wirft es SubmissionNotAcceptableException.
Was drinsteht
| Methode | Bedeutung |
|---|---|
id() / caseId() | UUIDs von Transfer und Vorgang |
getDataAsString() | Fachdaten als UTF-8-Text |
getAttachments() | List<Attachment>, entschlüsselt |
addressing() | ByService oder ByProcessMessage |
replyChannel() | Optional<JWK> — Antwortschlüssel |
senderDestinationId() | UUID, rein informativ |
Antworten abholen
OnlineService svc = sdk.onlineService(MY_C_ID, replyKeys);
for (ReplyForPickup waiting : svc.awaitingReplies()) {
ReceivedReply reply = svc.receive(waiting);
if (reply.report().acceptable()) {
svc.accept(reply);
} else {
svc.reject(reply, reply.report().asProblems());
}
}
| Methode | Bedeutung |
|---|---|
id() / caseId() | Antwort- und Case-UUID |
getDataAsString() | Antwortdaten als Text |
getAttachments() | Anhänge der Antwort |
senderDestinationId() | antwortende Stelle |
Antworten
A/B → C: eine Antwort geht immer an einen Case, nie an eine Destination.
OutgoingReply
// Weg 1 — direkt aus dem empfangenen Antrag (nimmt Case + Schlüssel von dort)
SentReply sent = amt.send(
OutgoingReply.answering(received)
.setData(SubmissionData.json(bescheid, schemaUri)) // Pflicht
.addAttachment(Attachment.fromFile(pdf, "application/pdf"))
.forProcessMessage(processModelId, messageId) // Prozesskommunikation
.preferMetadataVersion(MetadataVersion.V3)
.build());
// Weg 2 — später, ohne den Antrag noch im Speicher zu haben
OutgoingReply.answering(caseId, replyEncryptionKeyJwk).setData(...).build();
Wichtig: answering(received) wirft eine FitConnectReplyException, wenn der Antrag keinen
FIT-Connect-Rückkanal angeboten hat. Genau so setzt der Code R3 durch: A/B-Absender bieten keinen
an, also ist A/B → A/B einseitig.
ReplyKeys
// Funktionales Interface: Case-ID → Schlüssel
ReplyKeys keys =
ReplyKeys.of(Map.of(caseId, privateJwk));
ReplyKeys keys = vault::lookupReplyKey; // Methodenreferenz
ReplyKeys keys = ReplyKeys.none(); // nur senden
Passend zu R4: ReplyKeys öffnen Antworten auf selbst gesendete Anträge (nur C), DestinationKeys
öffnen an Sie adressierte Anträge (nur A/B). Keine Destination hält je beides.
Cases & Transferlog
Vorgänge
List<Case> all = cases.list();
// Case eröffnen
Case c = cases.open(publicService);
// bei fremder Destination
Case c = cases.open(publicService, atDestinationId);
// ganzer Vorgang
List<CaseEvent> events = cases.events(caseId);
events(caseId) liefert das Log des Vorgangs, logOf(nachricht) das Log eines einzelnen
Transfers. Die Namen sind absichtlich verschieden.
Status einer Nachricht
TransferLog log = cases.logOf(sentSubmission);
// nach issueTime, wirft bei leerem Log
CaseEvent neuest = log.latest();
EventState state = neuest.state();
EventState | |
|---|---|
SUBMITTED | eingereicht |
ACCEPTED | angenommen |
REJECTED | zurückgewiesen |
FORWARDED | weitergeleitet |
NOTIFIED | benachrichtigt |
DELETED | gelöscht |
INCOMPLETE | unvollständig |
Wer sieht welches logOf
| Argument | A/B | C |
|---|---|---|
SentSubmission | ja | ja |
SubmissionForPickup | ja | — |
SentReply | ja | — |
ReplyForPickup | — | ja |
Deshalb OrganisationCases und OnlineServiceCases statt eines gemeinsamen breiten Typs.
Routing
Zuständige Destinations finden
Routing rt = sdk.directory().routing();
// Suche: LeiKa-Schlüssel + genau EIN Gebietskriterium
List<Route> r = rt.routes(DestinationSearch.withArs(leikaKey, ars));
List<Route> r = rt.routes(DestinationSearch.withAgs(leikaKey, ags));
List<Route> r = rt.routes(DestinationSearch.withAreaId(leikaKey, areaId));
| Feld | Muster | Hinweis |
|---|---|---|
leikaKey | 99 + 12 Ziffern | Pflicht |
ars | 2/3/5/9/12 Ziffern | Regionalschlüssel |
ags | 2/3/5/8 Ziffern | Gemeindeschlüssel |
areaId | Ziffern | Gebiets-ID |
Genau eines von ars/ags/areaId setzen — keines oder mehrere werfen sofort im Konstruktor.
Default-Limit 500.
Verwaltung
Destinations pflegen
DestinationClient d = sdk.manage().destinations();
// vollständig ersetzen
d.updateDestination(id, updateDestination);
// teilweise ändern
d.patchDestination(id, destinationPatch);
Patch statt Update, wenn nur einzelne Felder geändert werden: bei DestinationPatch werden
ausgelassene Felder nicht mitgesendet und damit serverseitig nicht geleert.
Schlüssel und Limits
d.addKeyToDestination(id, publicKey); // Rollover
DestinationLimits l = d.getAttachmentLimits(id);
d.requestLimitChange(
id, LimitDirection.SEND, limits, grund);
Limits gibt es getrennt für Sende- und Empfangsrichtung; ohne LimitDirection gilt die
Standardrichtung.
Anhangspeicher
AttachmentManager a = sdk.manage().attachments();
// InputStream
a.loadAttachment(submissionId, attachmentId);
a.getTotalSize(); a.getFileCount();
Standard ist der lokale FileSystemStorageProvider. Eigenen Speicher (S3, Datenbank) über
AttachmentStorageProvider implementieren und via customization().withCustomStorageProvider(…)
einhängen.
Schlüssel & Krypto
Schlüssel einer A/B-Destination
new DestinationKeys(decryptionJwk, signatureJwk);
// Rollover
new DestinationKeys(List.of(alt, neu), signatureJwk);
// nur senden
DestinationKeys.none();
Der Signaturschlüssel signiert die Event-Log-Einträge (JWS).
Testschlüssel & Public Keys
// RSA-4096-Paar für Verschlüsselung (RSA-OAEP-256)
JWKPair pair = EncryptionKeyPairGenerator.generate();
EncryptionKey k =
sdk.directory().activeEncryptionKeyOf(destinationId);
Wichtig: Produktivschlüssel gehören in ein HSM oder einen Vault, nicht neben die Credentials in eine Datei.
Signatur verifizieren
ValidationResult r = CallbackValidationUtil.validateCallback(
hmacHeader, timestampInSec, httpBody, callbackSecret);
if (!r.isValid()) { /* 401 */ }
Prüft HMAC und Zeitstempel des von FIT-Connect gesendeten Callbacks, bevor der Prozess neue Nachrichten abholt.
Validierung & Metadaten
ValidationConfig.builder()
.validateMetadata(true) // Schema, Auth-Tags, Service, Rückkanal
.validateData(true) // Hash, Syntax, JSON-Schema
.validateAttachments(true) // Hash, Auth-Tag, Inhalt
.build();
// kein enableAutoReject mehr (seit 4.0.0-rc.1) — Befunde landen im ReceiveReport,
// siehe „Empfangen" oben bzw. Validierungsdiagnose statt Auto-Reject
| Gesetzte Felder | Version |
|---|---|
authenticationInformation | V1 (1.6.0) |
author / dataSets + Root-Rückkanal | V2 (2.1.0) |
| sonst | V3 (3.0.0) |
authenticationInformation und author/dataSets gleichzeitig zu setzen wirft eine
FitConnectSenderException: 1.x und 2.x schließen sich aus.
Problems (Auswahl, aus rest.model.event.problems) — landen im Event-Log und sind für den
Absender lesbar:
| Gruppe | Beispiele |
|---|---|
| metadata | MetadataSchemaViolation, UnsupportedService, UnsupportedReplyChannel |
| data | DataHashMismatch, DataSchemaViolation, DataEncryptionKeyIssue |
| attachment | AttachmentHashMismatch, MissingAttachment, AttachmentEncryptionKeyIssue |
| security | MalwareDetected |
Virenschutz: Modul virus-scanner hinzufügen — das SDK scannt dann Fachdaten, Metadaten und
Anhänge (CLAMAV_DAEMON · CLAMAV_PROCESS · ICAP · NO_OP). Details siehe
Virenscanner anbinden.
Konfiguration
credentials:
clientId: "your-client-id"
clientSecret: "your-client-secret"
environment: "TEST" # TEST | STAGE | PROD
sdkSettings:
httpConfig:
timeoutConfig: { readTimeout: 30, writeTimeout: 30, connectionTimeout: 30, callTimeout: 60 }
retryConfig:
allowRetries: true
retryableStatusCodes: [408, 429, 500, 502, 503, 504]
attachmentChunkingConfig:
chunkAllAttachments: false
attachmentStoragePath: "/tmp/fit-connect-attachments"
validationConfig:
validateMetadata: true
Unbekannte Felder werden ignoriert, fehlende mit Defaults gefüllt. Dieselben Schlüssel gibt es als
Builder — SdkSettings.builder().httpConfig(HttpConfig.builder().timeouts(…).retryConfig(…).build()) .attachmentChunkingConfig(…).validationConfig(…).build() — für FitConnectSdk.fromConfigBuilder()…settings(…).
| Auth / API | Unsichere Keys | |
|---|---|---|
TEST | *.fit-connect.fitko.dev | erlaubt |
STAGE | stage.fit-connect.fitko.net | nein |
PROD | prod.fit-connect.fitko.net | nein |
Ohne Angabe gilt TEST. STAGE nutzt die Produktiv-Routing-API — es gibt keine eigene.
| Wert | Bedeutung |
|---|---|
| 13 MB | max. Inline-Fachdaten, darüber automatisch als Anhang |
| 500 | Default-Limit für Cases, Replies, Submissions, Routing |
| 3 | parallele Anhang-Streams |
| 10 MB | Chunk-Größe beim gestückelten Upload |
Fehlerbehandlung
Alle erben von FitConnectException (unchecked).
| Exception | Auslöser |
|---|---|
FitConnectConfigurationException | Fehlende Credentials, kaputte YAML, unbekannte Destination |
FitConnectAuthorizationException | OAuth-Token abgelehnt, fehlender Scope |
FitConnectCryptoException | Ver-/Entschlüsselung, Signatur, ungeeignetes Schlüsselmaterial |
FitConnectValidationException | Prüfung von Metadaten, Daten oder Anhängen fehlgeschlagen |
FitConnectSchemaException | Schema nicht auflösbar oder nicht unterstützt |
FitConnectSenderException | Sendeseitig inkonsistent, z. B. Metadaten-Versionskonflikt |
FitConnectReplyException | Kein FIT-Connect-Rückkanal vorhanden |
FitConnectAttachmentException | Anhang-Upload, Chunking, Hash |
FitConnectStorageException | Lesen/Schreiben im Anhangspeicher |
FitConnectRoutingException | Routing-Suche oder ungültige Routing-Signatur |
Kommandozeile
fit-connect [-c config.yml] [--json] [-v] <rolle> <befehl> [...] # fit-connect = java -jar fit-connect-cli.jar
| Befehl | Zweck |
|---|---|
organisation awaiting · receive · reject · send · batch · cases {list,events,log} | Als Typ-A/B-Zustellpunkt handeln — entspricht sdk.organisation(…) |
online-service send · batch · awaiting-replies · receive · cases {…} | Als Typ-C-Zustellpunkt handeln — entspricht sdk.onlineService(…); Rückkanalschlüssel per --reply-key <case-id>=<jwk> |
directory for-service · for-process-message · key · routes · areas | Unauthentifiziert suchen — entspricht sdk.directory() |
destinations show · list · limits · keys | Eigene Zustellpunkte einsehen — entspricht sdk.manage().destinations() |
keygen [--out-dir D] [--with-config] | JWK-Paare für TEST erzeugen, optional mit fertiger config.yml |
doctor [<destination-id>] | Selbstdiagnose — entspricht sdk.diagnose(…) |
Ohne -c wird config.yml im Arbeitsverzeichnis gesucht; sie braucht zusätzlich den
CLI-eigenen identity-Block (Destination + Schlüsselpfade). Ergebnisse gehen nach stdout
(--json maschinenlesbar), Exit-Codes 0/1/2/3. Details:
Kommandozeile & Beispielprojekte.
Fallstricke
Falsche Erwartungen ans Modell
- Eine Antwort an eine Destination adressieren zu wollen — Antworten gehen an den Case.
- Erwarten, dass eine
OrganisationAntworten empfängt — tut sie nicht, das ist korrekt (R3). - Einen Antrag an eine C-Destination senden — nur A und B sind Empfänger (R2).
- Eine Destination mit
DestinationKeysundReplyKeysausstatten — die Identitäten sind disjunkt.
Typische Laufzeitfehler
OutgoingReply.answering(received)ohne angebotenen FIT-Connect-Rückkanal — wirft.- Prozessadressierter Antrag ohne Case —
build()wirft. log.latest()auf einem leeren Event-Log — wirft; vorherentries()prüfen.updateDestinationstattpatchDestinationfür Teiländerungen — leert Felder.
Vor dem Go-Live prüfen
- Diagnosebericht immer vor
acceptprüfen (received.acceptable()) — es gibt kein Auto-Reject mehr, das ungültige Nachrichten von selbst aussortiert. - Private Schlüssel nicht neben die Credentials in die YAML legen — sie sind Argumente.
- Beim Schlüsselwechsel alten und neuen Schlüssel gleichzeitig in
DestinationKeyshalten. attachmentStoragePathsetzen — der Default liegt im temporären Verzeichnis.
Ausführlich erklärte Schritt-für-Schritt-Rezepte finden Sie unter Submission senden, Submission empfangen und den weiteren Seiten unter Java & .NET SDK → Rezepte in der Seitenleiste. Änderungen zwischen SDK-Versionen: Changelog.