AI translation
This page was translated from German with AI. If in doubt, the German original applies.
Getting started¶
This page takes you from zero to your first successful call against ZEDAL — once via REST/JSON and once via the .NET SDK. Both paths lead to the same backend methods and use the same auth model; you only need one of them.
| Path | When | Section |
|---|---|---|
| REST / JSON | Any language with an HTTP client. No codegen, no SDK. | First call via REST |
| .NET SDK | C# applications. Encapsulates token refresh, document lifecycles, retries. | First call via the .NET SDK |
If you want to generate native gRPC code, you will find the basics under API reference → gRPC; the auth flow on this page applies there unchanged.
Prerequisites and access¶
To make a call, you need four things:
| Item | What it is | Where from |
|---|---|---|
| Provider | Numeric provider_id of the ZEDAL instance your account resides on. It applies to exactly one environment. |
from the access email; DiscoveryApi/ListProvider returns the list, see Step 0 |
| Subscriber + account + password | Contract tenant and the user account within it. | ZEDAL support |
| App token | JWT that identifies your application and defines its permission profile. It mirrors the permission table from the portal — whatever the app token does not unlock, the account cannot do via the API. | ZEDAL support |
| Environment | Test / Preview / Production, see Environments. | your decision |
App tokens and account data are not distributed publicly. Send an email to erp-tools@zedal.de. So that the request can be answered in one go, include:
- Integrating company — name and address.
- Application — name and version. Both are included in every call as
clientName/clientVersionand therefore appear in the server-side logs; choose values you want to keep permanently. - Target environment — Test, Preview or Production. To get started: Test.
- Provider — if already known, the provider short name or the
provider_id; otherwise “please assign”. - Business scope — which roles and processes you want to cover (e.g. DIWASS notification and movement documents from the company perspective, or the authority perspective). The permission profile of the app token is derived from this.
- Login type — interactive user login (
UsernameCredentials) or unattended process. An unattended process needsClientIdCredentials; you generate the requiredclient_id/client_secretpair yourself viaAuthApi/IssueClientId— for this, your app token must carry theusermgtscope. So state whether you need this scope. - Contacts — technical and business.
You receive in return: app token, subscriber, account, initial password and the provider_id.
The client_secret is shown exactly once
AuthApi/IssueClientId returns clientId and clientSecret in the response. AuthApi/ListClientIds does not return the secret afterwards. If you lose it, you must revoke the client ID via AuthApi/RevokeClientId and issue a new one — the revocation also ends every active session of this machine client.
Environments¶
| Environment | Purpose | Native gRPC | REST / JSON and gRPC-Web |
|---|---|---|---|
| Production | production operation | api.live.zedal.de |
openapi.live.zedal.de |
| Preview | staging and beta systems | api.preview.zedal.de |
openapi.preview.zedal.de |
| Test | test and integration systems | api.test.zedal.de |
openapi.test.zedal.de |
| Development | internal development systems | api.dev.zedal.de |
openapi.dev.zedal.de |
Develop against Test. This environment is intended for integrations and is operated as such. Development is the platform’s internal development stage — it can change at any time without notice and is not intended for external integrators. Preview is the preview state ahead of a production release; testing there only makes sense once your integration runs against Test.
Each environment serves only its own providers. A provider_id that works on Test is not served on Production and vice versa — not even if DiscoveryApi/ListProvider lists it on both hosts: the discovery list is a cross-environment directory, not a routing promise. What counts is the provider_id you were given for this environment.
The auth model¶
The flow has three stages and is identical for REST, gRPC and the SDK.
sequenceDiagram
participant App as Your application
participant Z as ZEDAL
App->>Z: AuthApi/IssueRefreshToken<br/>appToken + credentials
Z-->>App: refreshToken
Note over App: Store refreshToken securely
App->>Z: AuthApi/RequestAccess<br/>Authorization: Bearer refreshToken
Z-->>App: accessToken + newRefreshToken
Note over App,Z: The submitted refreshToken is invalid from here on.<br/>Persist newRefreshToken before continuing.
App->>Z: Business call<br/>Authorization: Bearer accessToken
Z-->>App: Result
App->>Z: AuthApi/RevokeRefreshToken (logout)
| Stage | Operation | Authentication | Result |
|---|---|---|---|
| 1 — Login | AuthApi/IssueRefreshToken |
none (appToken + credentials in the body) |
refreshToken |
| 2 — Get access | AuthApi/RequestAccess |
Authorization: Bearer <refreshToken> |
accessToken and newRefreshToken |
| 3 — Business call | every other operation | Authorization: Bearer <accessToken> |
— |
| Logout | AuthApi/RevokeRefreshToken |
Authorization: Bearer <refreshToken> |
session ended |
Two credential types are available. Both also require the app token:
| Type | Fields | For |
|---|---|---|
usernameCredentials |
subscriberId, accountId, password |
user-bound applications |
clientIdCredentials |
clientId, clientSecret |
unattended processes |
Validity¶
| Token | Renewal | Where you find the validity |
|---|---|---|
| Access token | via RequestAccess with the current refresh token — no new login |
exp claim in the token |
| Refresh token | every use returns a new token | exp claim in the token |
Do not assume a fixed lifetime in the client
Both tokens are JWTs and carry their expiry time in the exp claim. The actual duration is
set server-side at issuance and can change — read it from the token instead of storing it
in the client. A hard-coded assumption leads either to expired calls or to unnecessary
refreshes.
In the .NET SDK, the expiry time is in Jwt.ValidTo. The SDK renews the access token on its own
as soon as less than five minutes of remaining lifetime are left.
As long as your process uses the refresh token before it expires, the session persists. After that, you no longer need the original credentials and should not store them permanently in the application.
Token rotation — where most integrations fail¶
Every RequestAccess invalidates the submitted refresh token
Stage 2 always returns a new refresh token (newRefreshToken), and the submitted one loses its validity at the same moment. Persist the new token before you make the next business call.
Symptom if you don’t: the first run works, the second fails at stage 2 with HTTP 401 and {"code":16,"message":"The token '…' is invalid."}. From the outside, this looks like an expired token — in fact, it is an overwritten one.
Parallel processes lock each other out
Two processes using the same stored refresh token invalidate each other on refresh: the second RequestAccess submits a token that the first has already consumed. Sooner or later both end up with HTTP 401, and a restart does not help — the stored token is dead; only a new login with credentials helps.
Two viable patterns:
- One refresher. Exactly one component holds the session and renews it; all other processes get the current access token from it. Within a single process, a lock around the refresh is sufficient.
- One session per process. Each process logs in once on its own and keeps its own refresh token in its own storage. Sessions run independently of each other.
What does not work: the same refresh token in multiple processes or on multiple machines.
Some operations additionally require a scope — for example usermgt for the administrative operations of the AuthApi. Scopes are tied to the app token and the subscriber assignment and are carried as a claim in the access token; they are not requested per call.
First call via REST¶
The base URL is the openapi.* host of the selected environment; the path is the fully qualified gRPC method name:
The complete list of all 131 operations is in the API reference; details on the call format are under REST / JSON.
The provider_id header is mandatory on every call
All providers of an environment share the same API host. Without provider_id, the platform cannot assign the call to an instance — not even at login.
Symptom: HTTP 501 with the body {"code":12,"message":"Bad gRPC response. HTTP status code: 404","details":[]}. The text suggests a wrong path; in fact, the call cannot be assigned. Two causes are possible: the header is missing — or it names a provider that this environment does not serve.
The only exception is DiscoveryApi/ListProvider — the operation is cross-provider and unauthenticated.
The following steps use curl and jq against the Test environment. First, set the base values:
export ZEDAL_HOST=https://openapi.test.zedal.de
export ZEDAL_APP_TOKEN='<app token from the access email>'
export ZEDAL_PASSWORD='<account password>'
Step 0: Determine the provider¶
The only call that works without a token and without provider_id. It is also the fastest connectivity test.
curl -s "$ZEDAL_HOST/infotech.zedal.provider_discovery.v1.api.DiscoveryApi/ListProvider" \
| jq '.provider[] | {id, shortName, type}'
{ "id": 5, "shortName": "DEV", "type": "TEST" }
{ "id": 9, "shortName": "DEMO", "type": "DEMO" }
{ "id": 14, "shortName": "TRAINING", "type": "DEMO" }
Set the id you were given for this environment:
The full response additionally contains name, vendor, portalUrl, soapUrl and grpcUrl for each provider.
Step 1: Login — get a refresh token¶
REFRESH=$(curl -s -X POST \
"$ZEDAL_HOST/infotech.zedal.core.v1.api.AuthApi/IssueRefreshToken" \
-H 'Content-Type: application/json' \
-H "provider_id: $ZEDAL_PROVIDER" \
-d '{
"appToken": "'"$ZEDAL_APP_TOKEN"'",
"usernameCredentials": {
"subscriberId": "mysubscriber",
"accountId": "myaccount",
"password": "'"$ZEDAL_PASSWORD"'"
}
}' \
| jq -r .refreshToken)
For an unattended process, the clientIdCredentials block with clientId and clientSecret takes the place of usernameCredentials; everything else stays the same.
Step 2: Get an access token¶
RESPONSE=$(curl -s -X POST \
"$ZEDAL_HOST/infotech.zedal.core.v1.api.AuthApi/RequestAccess" \
-H 'Content-Type: application/json' \
-H "provider_id: $ZEDAL_PROVIDER" \
-H "Authorization: Bearer $REFRESH" \
-d "{\"refreshToken\": \"$REFRESH\"}")
ACCESS=$(echo "$RESPONSE" | jq -r .accessToken)
REFRESH=$(echo "$RESPONSE" | jq -r .newRefreshToken) # mandatory: the old one is now dead
The refresh token is in both the Authorization header and the refreshToken body field. Both are expected; the field from the body is the one that is evaluated and rotated.
The last line is where an integration stands or falls: in a real application, it has to be a write to the same storage from which the next process start reads the token — not a shell variable.
Step 3: Business call¶
DocumentApi/ListInbox lists the document IDs in the inbox. A GET with the filters in the query string — the verb is listed per operation in the reference and is not always POST.
curl -s -G "$ZEDAL_HOST/infotech.zedal.core.v1.api.DocumentApi/ListInbox" \
-H "provider_id: $ZEDAL_PROVIDER" \
-H "Authorization: Bearer $ACCESS" \
--data-urlencode 'from=2026-01-01T00:00:00Z' \
--data-urlencode 'to=2026-12-31T23:59:59Z' \
--data-urlencode 'unread.value=true'
Without filters, the operation lists all unread documents with no time limit. Timestamps are RFC 3339 in UTC. Nested fields are written with a dot in the query string (unread.value), as specified in the reference.
Reading errors¶
The response body always has the form {"code": <grpc-status>, "message": "…", "details": []}. The HTTP status is derived from the gRPC status.
| HTTP | Body | Cause |
|---|---|---|
501 |
{"code":12,"message":"Bad gRPC response. HTTP status code: 404"} |
provider_id header missing, or the named provider is not served by this environment |
400 |
{"code":3,"message":"MISSING_CREDENTIALS"} |
the login body contains neither usernameCredentials nor clientIdCredentials |
401 |
{"code":16,"message":"The token '…' is invalid."} |
token expired, rotated, revoked — or not a JWT at all |
401 |
{"code":16,"message":"The given authentication token is invalid."} |
Authorization header missing entirely |
This is the transport level. Business rejections from DIWASS have their own error codes and can be contained in an HTTP 200 response, because the call was technically successful — see Error codes.
Make calls traceable
The .NET SDK sends the clientName and clientVersion headers with every call; they end up in the server-side logs. Via REST, you set them yourself — the gateway passes custom headers through to the backend as gRPC metadata. Use the same values you provided at registration; this shortens every support request considerably.
First call via the .NET SDK¶
Installation¶
The target is netstandard2.0; versioning follows Semantic Versioning (1.2.0). The package pulls in the generated gRPC types transitively.
Create the client¶
using Zedal.Sdk;
using Zedal.Sdk.Impl; // TargetSystem
using Zedal.Sdk.Jwt; // AppToken, RefreshToken
var client = ZedalClientFactory.Default.CreateClient(
system: TargetSystem.Test, // Production | Preview | Test | Development
clientName: "MyIntegrationApp", // appears server-side as the caller
clientVersion: "1.0.0");
The SDK sets the addresses itself from TargetSystem; it attaches provider_id to every call once the session is established. You do not need to manage either yourself.
Transport — usually nothing to do
The transport parameter defaults to ZedalTransport.Auto: when the connection is established, a one-time check determines whether the native host speaks HTTP/2. Only if an HTTP/1.x restriction is confirmed does the SDK switch to ZedalTransport.GrpcWeb via the openapi.* host; if the check gets no response (offline, timeout), it stays with native gRPC. The check is a blocking network call in the constructor.
In environments where HTTP/2 is known to be blocked — old corporate proxies, .NET Framework — you can skip the detour and set transport: ZedalTransport.GrpcWeb directly. If traffic goes through a proxy, specify it via the proxyUrl parameter or the INFOTECH_GRPC_PROXY_URL environment variable.
Register the rotation callback — before login¶
SetAccessTokenChanged is called on every token rotation: after CreateSession, after ContinueSession and on every automatic refresh.
client.SetAccessTokenChanged(tokens =>
SecureStorage.Save("zedal-refresh-token", tokens.refreshToken.Jwt.EncodedToken));
tokens is a tuple (AccessToken accessToken, RefreshToken refreshToken). The encoded JWT string is in Jwt.EncodedToken; alternatively, ToByteArray() returns the same string as UTF-8 bytes.
In the examples, SecureStorage stands in for your protected storage — DPAPI, Keychain, a secret store. The refresh token allows any number of new access tokens to be issued; treat it like a password.
Without this callback, the session does not survive a restart
The SDK rotates the refresh token on every refresh. If you only store the token from the login, it will long since have been invalidated by the next program start — ContinueSession then fails with RpcException, status Unauthenticated, and you need the credentials again.
Register the callback before CreateSession and before ContinueSession: both already rotate on their own, and a callback registered afterwards misses exactly this first rotation.
Login and first call¶
await client.CreateSession(
providerId: int.Parse(Environment.GetEnvironmentVariable("ZEDAL_PROVIDER")),
subscriberId: "mysubscriber",
accountId: "myaccount",
password: Environment.GetEnvironmentVariable("ZEDAL_PASSWORD"),
appToken: new AppToken(Environment.GetEnvironmentVariable("ZEDAL_APP_TOKEN")));
var docIds = await client.CoreApi.Documents.ListInbox(
from: DateTime.UtcNow.AddDays(-30),
to: DateTime.UtcNow,
unread: true);
foreach (var docId in docIds)
{
Console.WriteLine(docId.Id);
}
This is the same call as Step 3 in the REST example. Two details:
fromandtomust be UTC. ADateTimewith aKindother thanUtcmakes the Protobuf conversion fail withArgumentExceptionbefore a call is even sent.- The SDK renews access tokens automatically as soon as less than five minutes of remaining lifetime are left. You do not need to call
RequestAccessyourself.
Besides the variant with separate fields, there are two other logins: CreateSession(userId, appToken) with the combined pattern <subscriber>-<account>@<provider>:<password>, and CreateSession(clientId, clientSecret, appToken) for unattended processes. Details in the .NET SDK documentation.
Restart: continue the session¶
var client = ZedalClientFactory.Default.CreateClient(
system: TargetSystem.Test,
clientName: "MyIntegrationApp",
clientVersion: "1.0.0");
// Callback first — ContinueSession already rotates.
client.SetAccessTokenChanged(tokens =>
SecureStorage.Save("zedal-refresh-token", tokens.refreshToken.Jwt.EncodedToken));
var refreshToken = new RefreshToken(SecureStorage.Load("zedal-refresh-token"));
await client.ContinueSession(refreshToken);
No credential login, no stored passwords. ContinueSession reads the provider_id from the token; you do not need to specify it again.
Logout¶
Ends the session server-side and renders the refresh token unusable. After that, only a login with credentials is possible — delete the stored token at the same time.
Timeouts¶
The client’s default timeout is 30 seconds. For calls that are forwarded to DIWASS on the server side, this is too short; set the time limit per call:
await client.DiwassApi.AttachmentApi.GetAttachment(attachmentIdentifier,
ZedalCallContext.Default.WithTimeout(600));
ZedalCallContext controls the deadline, CancellationToken and additional headers. WithoutDeadline() disables the time limit entirely — only sensible together with a CancellationToken. A missed deadline comes back as RpcException with StatusCode.DeadlineExceeded and looks like a server error, but is not one. Concrete guideline values per process are in the DIWASS guides.
Where to go next¶
- DIWASS guides — transboundary shipment of waste: notification, movement documents, authority perspective
- REST / JSON — call format, header pass-through, streaming limits, code generation
- Field rules — formats and value ranges that the schema does not express
- Error codes — what a business rejection means and what to do
- Code lists — permitted values for controlled fields