Skip to content

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:

  1. Integrating company — name and address.
  2. Application — name and version. Both are included in every call as clientName / clientVersion and therefore appear in the server-side logs; choose values you want to keep permanently.
  3. Target environment — Test, Preview or Production. To get started: Test.
  4. Provider — if already known, the provider short name or the provider_id; otherwise “please assign”.
  5. 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.
  6. Login type — interactive user login (UsernameCredentials) or unattended process. An unattended process needs ClientIdCredentials; you generate the required client_id/client_secret pair yourself via AuthApi/IssueClientId — for this, your app token must carry the usermgt scope. So state whether you need this scope.
  7. 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:

<HTTP verb> https://openapi.<environment>.zedal.de/<service.FullName>/<method>

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:

export ZEDAL_PROVIDER='<your provider_id>'

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'
{ "docIds": [ { "id": "…" }, { "id": "…" } ] }

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

dotnet add package Zedal.Sdk

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:

  • from and to must be UTC. A DateTime with a Kind other than Utc makes the Protobuf conversion fail with ArgumentException before 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 RequestAccess yourself.

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

await client.EndSession();

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