Skip to content

AI translation

This page was translated from German with AI. If in doubt, the German original applies.

Samples

Runnable console samples for the core use cases: finding the provider, logging in, submitting a DIWASS notification, creating a new transport paper (movement) and retrieving the data for it, fetching transport paper numbers, fetching changes and retrieving the documents that changed since a point in time. The package is a standalone .NET project that builds against the NuGet package Zedal.Sdk. The code on this page is the development state.

Download

State SDK version Download
Development state (main, 555a19e1) 1.3.0 zedal-sdk-samples-latest.zip

The development state follows the SDK on master and may require an SDK version that is not yet published on NuGet; it then builds only with that release. A release carries the SDK version it builds against.

Prerequisites

  • .NET 9 SDK.
  • Access to the Test environment: provider id, subscriber, account and password. You get them from ZEDAL support; what to include in the request is listed under Getting started.
  • DIWASS operator registration for notification, movement and changes: your company must be registered as a DIWASS operator, and the operator id must be assigned to your ZEDAL account. Carrier, consignee and facility of the sample notification must be registered operators with an EORI number.
  • Develop against Test. Nothing in DIWASS is a draft: a notification sent via the API is submitted to the authorities right away, a movement is announced right away.

The samples log in with their own app token (scope core, in SampleSettings.cs). Your integration gets its own app token from ZEDAL support.

Running a sample

In the unpacked folder:

dotnet run -- <sample> [arguments]

Without arguments, or with help, the program lists the samples and the environment variables. Exit codes: 0 success, 1 the sample failed, 2 usage or configuration error.

Variable Needed by Meaning
ZEDAL_TARGET all Test, Preview, Production or Development; default Test
ZEDAL_PROVIDER_ID all except providers Numeric provider id of the target environment
ZEDAL_SUBSCRIBER all except providers Subscriber, the actual login
ZEDAL_ACCOUNT optional Account below the subscriber; leave it empty if your login has no separate account
ZEDAL_PASSWORD all except providers Password of the account, or of the subscriber
ZEDAL_REFRESH_TOKEN_FILE login File for the refresh token; default zedal-refresh-token.txt in the working directory
ZEDAL_CARRIER_EORI notification EORI of the carrier
ZEDAL_CONSIGNEE_EORI notification EORI of the consignee
ZEDAL_FACILITY_EORI notification EORI of the facility (may be the consignee’s)
ZEDAL_DIWASS_WSSE_USER changes, optional DIWASS WSSE user; the sample queries the event list only if it is set
ZEDAL_DIWASS_WSSE_PASSWORD changes, optional DIWASS WSSE password
export ZEDAL_TARGET=Test
export ZEDAL_PROVIDER_ID=<provider id>
export ZEDAL_SUBSCRIBER=<subscriber>
export ZEDAL_ACCOUNT=<account>        # omit if there is none
export ZEDAL_PASSWORD=<password>
$env:ZEDAL_TARGET = "Test"
$env:ZEDAL_PROVIDER_ID = "<provider id>"
$env:ZEDAL_SUBSCRIBER = "<subscriber>"
$env:ZEDAL_ACCOUNT = "<account>"
$env:ZEDAL_PASSWORD = "<password>"

The samples

Finding the provider: providers

dotnet run -- providers

Lists id and name of the providers known to the target environment, without login. The list is a directory across all environments; use the provider id you were given for your environment.

ProvidersSample.cs
namespace Zedal.Sdk.Samples;

/// <summary>
///     Lists the ZEDAL providers without logging in. Use it to look up the provider id
///     before the first login.
/// </summary>
public static class ProvidersSample
{
    public static async Task RunAsync()
    {
        var target = SampleSettings.ReadTarget();

        // Provider discovery is not tied to a provider and needs no session.
        var client = SampleSettings.CreateClient(target);
        var providers = (await client.DiscoveryApi.ListProvider()).OrderBy(p => p.Id).ToList();

        Console.WriteLine($"{providers.Count} provider(s) listed by {target}:");
        Console.WriteLine();
        Console.WriteLine($"  {"Id",5}  Name");
        foreach (var provider in providers)
        {
            Console.WriteLine($"  {provider.Id,5}  {provider.Name}");
        }

        // The list is a directory across all environments, not a routing promise: a provider
        // id only works on the environment it was issued for.
        Console.WriteLine();
        Console.WriteLine("Use the provider id you were given for this environment as ZEDAL_PROVIDER_ID.");
    }
}

Login and session: login

dotnet run -- login

Logs in with separate fields, stores the refresh token in the rotation callback, shows how long the tokens are valid (read from the tokens, not assumed), continues the session from the stored token as a restarted process would, and logs out. Every token refresh invalidates the previous refresh token; background under Sessions and tokens.

LoginSample.cs
using Zedal.Sdk.Jwt;

namespace Zedal.Sdk.Samples;

/// <summary>
///     Session handling: log in, persist the rotating refresh token, continue the session
///     from the stored token (as after a restart) and end it.
/// </summary>
public static class LoginSample
{
    public static async Task RunAsync()
    {
        var tokenFile = SampleSettings.RefreshTokenFile;

        // 1) Log in with credentials. Every token refresh hands out a new refresh token and
        //    invalidates the previous one. The callback is the only place where you learn the
        //    current one, so store it right away - otherwise the next start fails with
        //    "Unauthenticated". The callback is registered before the login, because the
        //    login itself already rotates.
        var client = await SampleSettings.CreateLoggedInClientAsync(
            tokens => SaveRefreshToken(tokenFile, tokens.refreshToken));

        PrintTokenValidity("after login", client);

        // 2) Continue the session from the stored refresh token, as a restarted process would.
        //    A refresh token belongs to exactly one process: from here on the first client's
        //    token is used up, and only the second client holds a valid session.
        var restarted = SampleSettings.CreateClient(SampleSettings.ReadTarget());
        restarted.SetAccessTokenChanged(tokens => SaveRefreshToken(tokenFile, tokens.refreshToken));

        var storedToken = new RefreshToken((await File.ReadAllTextAsync(tokenFile)).Trim());
        await restarted.ContinueSession(storedToken);
        Console.WriteLine($"Session continued from {tokenFile}.");

        PrintTokenValidity("after ContinueSession", restarted);

        // 3) End the session. This revokes the refresh token on the server, so the stored copy
        //    is worthless now. EndSession does not report a failed revocation; use
        //    CoreApi.Auth.RevokeRefreshTokenByToken if you must be sure.
        await restarted.EndSession();
        File.Delete(tokenFile);
        Console.WriteLine("Session ended, stored refresh token deleted.");
    }

    private static void SaveRefreshToken(string path, RefreshToken refreshToken)
    {
        // A plain file keeps the sample simple. In production store the refresh token like a
        // password: OS key store, secrets vault or an encrypted database column.
        var options = new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write };
        if (!OperatingSystem.IsWindows())
        {
            options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
        }

        using (var writer = new StreamWriter(path, options))
        {
            writer.Write(refreshToken.Jwt.EncodedToken);
        }

        Console.WriteLine($"  [callback] refresh token rotated and saved to {path}");
    }

    private static void PrintTokenValidity(string when, IZedalClient client)
    {
        // Lifetimes are set by the server and may change. Read them from the token's exp
        // claim instead of assuming a fixed duration. The SDK refreshes the access token on
        // its own when less than five minutes are left.
        var accessToken = client.CoreApi.Auth.AccessToken;
        var refreshToken = client.CoreApi.Auth.RefreshToken;
        var now = DateTime.UtcNow;

        Console.WriteLine($"Tokens {when}:");
        Console.WriteLine($"  access token  valid until {accessToken.Jwt.ValidTo:yyyy-MM-dd HH:mm:ss} UTC " +
                          $"({FormatRemaining(accessToken.Jwt.ValidTo - now)} left)");
        Console.WriteLine($"  refresh token valid until {refreshToken.Jwt.ValidTo:yyyy-MM-dd HH:mm:ss} UTC " +
                          $"({FormatRemaining(refreshToken.Jwt.ValidTo - now)} left)");
    }

    private static string FormatRemaining(TimeSpan remaining) =>
        remaining <= TimeSpan.Zero
            ? "expired"
            : remaining.TotalHours >= 48
                ? $"{remaining.TotalDays:0.#} days"
                : $"{(int)remaining.TotalHours}h {remaining.Minutes:00}m";
}

Submitting a notification: notification

export ZEDAL_CARRIER_EORI=<EORI>
export ZEDAL_CONSIGNEE_EORI=<EORI>
export ZEDAL_FACILITY_EORI=<EORI>
dotnet run -- notification

Takes your own DIWASS operator id from the account (notifier and waste producer), resolves the other parties by EORI, builds the submission, stores it in ZEDAL, sends it to DIWASS and waits for the receipt. It prints the notification id and the follow-up intents. The waste, quantities and competent authorities in DiwassSampleData.cs are sample values; adapt them before you run the sample. Flow and stages: Notification (company role).

NotificationSample.cs
using Zedal.Sdk.DocumentHandler;

namespace Zedal.Sdk.Samples;

/// <summary>
///     Submits a DIWASS notification as a company: the logged-in account is notifier and
///     waste producer, the other parties are resolved from their EORI numbers.
/// </summary>
/// <remarks>
///     There is no draft status via the API. The first send is the submission: the
///     notification is immediately SUBMITTED at the authorities. Run this against Test only.
/// </remarks>
public static class NotificationSample
{
    public static async Task RunAsync()
    {
        var eoris = SampleSettings.ReadNotificationPartyEoris();
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            // 1) Own operator id: assigned to your account when your company was registered
            //    as a DIWASS operator. It goes into the document as OperatorInternalId.
            var ownOperatorIds = await client.MyAccount.GetOwnDiwassOperatorIds();
            if (ownOperatorIds.Count == 0)
            {
                throw new InvalidOperationException(
                    "No DIWASS operator id is assigned to this account. " +
                    "Ask ZEDAL support to register your company as a DIWASS operator.");
            }

            var notifierId = ownOperatorIds[0];
            Console.WriteLine($"Own DIWASS operator id (notifier and producer): {notifierId}" +
                              (ownOperatorIds.Count > 1 ? $" (of {string.Join(", ", ownOperatorIds)})" : ""));

            // 2) The other parties by EORI. DIWASS expects calls one after another: never fan
            //    them out in parallel (no Task.WhenAll). Violations can get the access blocked.
            var operators = new NotificationOperators(
                Notifier: notifierId,
                Carrier: await FindOperatorIdAsync(client, eoris.Carrier, "Carrier"),
                Consignee: await FindOperatorIdAsync(client, eoris.Consignee, "Consignee"),
                Facility: await FindOperatorIdAsync(client, eoris.Facility, "Facility"));

            // 3) Build the submission and let ZEDAL create the document. Nothing is sent yet.
            var handler = client.DocumentHandler.CreateNotificationHandler();
            await handler.CreateSubmission(DiwassSampleData.BuildSubmission(operators));

            // Check validation errors before sending - after sending it is a submitted notification.
            if (handler.HasValidationErrors)
            {
                PrintValidationErrors(handler);
                throw new InvalidOperationException("The submission has validation errors; nothing was sent.");
            }

            // 4) Store in ZEDAL. Keep the ZEDAL document id: it is the stable handle of the document.
            await handler.SaveToZedal();
            Console.WriteLine($"Saved in ZEDAL: document {handler.ZedalDocumentId.Id}, file {handler.TargetFileId?.Id}");

            // 5) Send to DIWASS. The client default of 30 seconds is too short for DIWASS paths,
            //    so the call gets its own deadline.
            await handler.SendToDiwass(ZedalCallContext.Default.WithTimeout(180));
            Console.WriteLine($"Handed over to DIWASS, transfer log {handler.LastTransferLogId?.Id}. Waiting for the receipt ...");

            // 6) Wait until DIWASS confirmed the receipt. Throws DiwassTransferFailedException if
            //    DIWASS rejects the document. Pushes take about a minute, so poll calmly.
            await handler.WaitForDiwassTransfer(
                timeout: TimeSpan.FromMinutes(3),
                pollInterval: TimeSpan.FromSeconds(5));

            // WaitForDiwassTransfer does not reload the document; the notification id is
            // only available after a reload.
            await handler.ReloadFromZedal();

            PrintResult(handler);
        }
        finally
        {
            await client.EndSession();
        }
    }

    private static async Task<long> FindOperatorIdAsync(IZedalClient client, string eori, string role)
    {
        var matches = await client.DiwassApi.OperatorApi.FindOperatorForEori(eori);
        if (matches == null || matches.Count == 0)
        {
            throw new InvalidOperationException($"{role}: no DIWASS operator found for EORI '{eori}'.");
        }

        if (matches.Count > 1)
        {
            // Never pick one at random - an ambiguous EORI has to be resolved by a person.
            var names = string.Join(", ", matches.Select(m => $"{m.InternalId?.Value} {m.Name?.Value}"));
            throw new InvalidOperationException($"{role}: EORI '{eori}' matches several DIWASS operators: {names}.");
        }

        var match = matches[0];
        Console.WriteLine($"{role,-9} EORI {eori} -> operator {match.InternalId.Value} ({match.Name?.Value})");
        return match.InternalId.Value;
    }

    private static void PrintValidationErrors(DiwassDocumentHandlerBase handler)
    {
        Console.WriteLine("Validation errors:");
        foreach (var error in handler.ValidationErrors)
        {
            Console.WriteLine($"  {error.Path}: {error.Error}");
        }
    }

    private static void PrintResult(NotificationDocumentHandler handler)
    {
        var notificationId = handler.DiwassNotificationId;

        // A rejected submission comes back with the notification id "UNKNOWN". Never store
        // that as the number of your case.
        if (string.IsNullOrEmpty(notificationId) || notificationId == "UNKNOWN")
        {
            Console.WriteLine($"DIWASS has not assigned a notification id yet (value: '{notificationId}').");
            Console.WriteLine("The answer arrives asynchronously: run the 'changes' sample later and check the document.");
        }
        else
        {
            Console.WriteLine($"Notification id: {notificationId}");
        }

        Console.WriteLine($"ZEDAL document:  {handler.ZedalDocumentId.Id}");

        // The follow-up intents are the state of the document: what can be done next.
        Console.WriteLine("Follow-up intents:");
        foreach (var intent in handler.FollowupIntents)
        {
            Console.WriteLine($"  {intent}");
        }

        if (handler.FollowupIntents.Count == 0)
        {
            Console.WriteLine("  (none yet)");
        }
    }
}
DiwassSampleData.cs
using Infotech.Zedal.Documents.V1;
using Infotech.Zedal.Documents.V1.Diwass.Waste.Model.V1;
using Infotech.Zedal.Documents.V1.Diwass.Waste.Movements.V1;
using Infotech.Zedal.Documents.V1.Diwass.Waste.Notification.V1;
using Zedal.Sdk.Apis.Diwass.Enums;
using static Zedal.Sdk.Protobuf.ProtoFields;
using DateTime = System.DateTime;

namespace Zedal.Sdk.Samples;

/// <summary>DIWASS operator ids (OperatorInternalId) of the parties of the sample notification.</summary>
public sealed record NotificationOperators(long Notifier, long Carrier, long Consignee, long Facility);

/// <summary>
///     Builds the DIWASS documents of the samples. All business values below are sample
///     data: replace them with the data of your shipment. The schema alone does not tell
///     you what DIWASS accepts - see "Field rules" and "Code lists" in the developer docs.
/// </summary>
public static class DiwassSampleData
{
    // Competent authority for dispatch and destination. Sample: a shipment within Germany
    // (country 276) handled by authority DE014. Replace both with the authorities that are
    // responsible for your shipment. CountryId is ISO 3166-1 numeric with three digits.
    public const string CompetentAuthorityId = "DE014";
    public const string CountryId = "276";

    // Contact person and signer of the sample documents. Use real people of your company.
    public const string ContactName = "Sample Contact";
    public const string ContactPhone = "+490000000000";
    public const string ContactEmail = "contact@example.com";

    // Free texts need a lowercase two-letter language code ("en", not "EN").
    private const string Language = "en";

    // Intended number of shipments. DIWASS accepts no more movements than this, and every
    // announced movement counts against it - also one you never use, unless it is cancelled.
    private const int ShipmentQuantity = 10;
    private const double TotalTonnes = 100.0;
    private const double MovementTonnes = 10.0;

    /// <summary>
    ///     Notification (submission) with the logged-in company as notifier and waste
    ///     producer, one carrier, one consignee and one disposal facility.
    /// </summary>
    public static SubmissionDocumentType BuildSubmission(NotificationOperators operators)
    {
        // PartyUuids are document-internal keys. Declarations and the shipment origin refer
        // to a party through them, so the same party must carry the same UUID everywhere.
        var notifierParty = NewPartyUuid();
        var producerParty = NewPartyUuid();
        var carrierParty = NewPartyUuid();
        var consigneeParty = NewPartyUuid();
        var facilityParty = NewPartyUuid();
        var exportAuthorityParty = NewPartyUuid();
        var importAuthorityParty = NewPartyUuid();

        var today = DateTime.UtcNow.Date;

        return new SubmissionDocumentType
        {
            Base = new BaseSubmissionDocumentType
            {
                // No NotificationId here: DIWASS assigns it. It is only set when updating.
                TakeBackIndication = Bf(false),
                IllegalActivityIndication = Bf(false),

                Submission = new SubmissionType
                {
                    Notifier = Operator(notifierParty, operators.Notifier),
                    Consignee = Operator(consigneeParty, operators.Consignee),

                    IndividualShipmentIndicator = Bf(false),
                    DestinedForRecoveryDisposal = Sf(FacilityTypeCode.Disposal.ToWire()),
                    PreConsentIndicator = Bf(false),
                    ShipmentQuantity = I32(ShipmentQuantity),

                    TotalWasteMeasurement = new NotificationMassVolumeMeasureType
                    {
                        MassMeasure = new MassMeasureType { Tonnes = Df(TotalTonnes) }
                    },

                    ShipmentPeriod = new ShipmentPeriodType
                    {
                        FirstStartDate = Date(today.AddDays(3)),
                        LastStartDate = Date(today.AddDays(90))
                    },

                    Packaging = new PackagingType
                    {
                        PackageTypeCode = { Sf(PackageTypeCode.Box.ToWire()) },
                        PackageDescription = Text("Boxes"),
                        SpecialHandlingRequirementIndicator = Bf(false)
                    },

                    // The shipment starts at the waste producer's site.
                    ShipmentOriginAddress = new ShipmentOriginAddressType { PartyUuid = Sf(producerParty) },

                    Carrier =
                    {
                        new CarrierType
                        {
                            Base = Operator(carrierParty, operators.Carrier),
                            MeansOfTransportCode = Sf(MeansOfTransportCode.Road.ToWire())
                        }
                    },

                    // The notifier is also the waste producer in this sample: same operator id,
                    // but a party of its own in the document.
                    WasteProducer =
                    {
                        new WasteProducerType
                        {
                            Base = Operator(producerParty, operators.Notifier),
                            SiteDescription = Text("Production site"),
                            ProcessDescription = Text("Production process")
                        }
                    },

                    RecoveryDisposalFacility =
                    {
                        new RecoveryDisposalFacilityType
                        {
                            Base = new FacilityType
                            {
                                Base = Operator(facilityParty, operators.Facility),
                                OperationalIndicators = new FacilityOperationalIndicatorsType
                                {
                                    InterimFacility = Bf(false),
                                    NextSubsequentInterimOrNonInterimFacilityInAnotherCountry = Bf(false),
                                    NextSubsequentInterimOrNonInterimFacilityInSameCountry = Bf(false)
                                },
                                SiteDescription = Text("Disposal site")
                            },
                            FacilityTypeCode = Sf(FacilityTypeCode.Disposal.ToWire())
                        }
                    },

                    DisposalRecoveryOperations = new DisposalRecoveryOperations
                    {
                        TechnologyDescription = Text("Landfill"),
                        ReasonForExportDescription = Text("Disposal"),
                        RecoveryDisposalTypeCode = { Sf(RecoveryDisposalCode.D1.ToWire()) }
                    },

                    WasteDesignationDescription = Text("Sample waste"),
                    PhysicalCharacteristicTypeCode = { Sf(PhysicalCharacteristicCode.Liquid.ToWire()) },
                    PhysicalCharacteristicDescription = Text("Liquid"),

                    // Waste code with the blanks between the digit groups and the list id token.
                    WasteClassification = new WasteClassificationType
                    {
                        WasteTypeCode =
                        {
                            new WasteCodeType { ListId = Sf(WasteCodeListId.Low.ToWire()), Code = Sf("02 07 01") }
                        }
                    },

                    ExportState = new ExportStateType
                    {
                        Base = ConcernedCountry(exportAuthorityParty),
                        ExitPointName = Sf("Exit point")
                    },
                    ImportState = new ImportStateType
                    {
                        Base = ConcernedCountry(importAuthorityParty),
                        EntryPointName = Sf("Entry point")
                    },

                    AnnexQuantity = I32(0)
                },

                // Exporter declaration = notifier, producer declaration = waste producer.
                DeclarationExporter = new DeclarationType
                {
                    PartyUuid = Sf(notifierParty),
                    Signature = Signature(ContactName, ContactEmail, "Notifier")
                },
                DeclarationProducer =
                {
                    new DeclarationType
                    {
                        PartyUuid = Sf(producerParty),
                        Signature = Signature(ContactName, ContactEmail, "Producer")
                    }
                }
            }
        };
    }

    /// <summary>
    ///     Movement template for one shipment of a consented notification. Carrier,
    ///     packaging and shipment origin are taken from the notification, because a movement
    ///     may only refer to what the authorities consented to.
    /// </summary>
    /// <param name="notificationId">DIWASS notification id, e.g. <c>DE0126005589i</c>.</param>
    /// <param name="sequenceNumber">Number of this shipment within the notification.</param>
    /// <param name="consented">Submission part of the consented notification.</param>
    public static MovementTemplateType BuildMovementTemplate(
        string notificationId, int sequenceNumber, SubmissionType consented)
    {
        if (consented.Carrier.Count == 0)
        {
            throw new InvalidOperationException($"Notification {notificationId} lists no carrier.");
        }

        var notifierContact = consented.Notifier?.Contact;

        var template = new MovementTemplateType
        {
            MovementDocumentDueToTakeBack = Bf(false),
            MovementDocumentsDueToIllegalActivity = Bf(false),

            Template = new MovementDocumentType
            {
                NotificationId = Sf(notificationId),
                SequenceNumber = I32(sequenceNumber),

                TransportAnnouncement = new TransportAnnouncementType
                {
                    ActualQuantity = new MassVolumeMeasureType
                    {
                        MassMeasure = new MassMeasureType { Tonnes = Df(MovementTonnes) }
                    },
                    StartDate = Date(PlannedStartDate(notificationId, consented.ShipmentPeriod)),
                    Packaging = new MovementPackagingType
                    {
                        Base = PackagingFrom(consented.Packaging),
                        PackageQuantity = I32(1)
                    },
                    // The carrier of this shipment; it must be one of the notification's carriers.
                    Carrier = { consented.Carrier[0].Clone() },
                    AdditionalInformation = Text("Announced with the ZEDAL SDK samples")
                },

                NotifierSignature = Signature(
                    notifierContact?.Name?.Value ?? ContactName,
                    notifierContact?.EmailUri?.Value ?? ContactEmail,
                    "Notifier")
            }
        };

        if (consented.ShipmentOriginAddress != null)
        {
            template.Template.ShipmentOriginAddress = consented.ShipmentOriginAddress.Clone();
        }

        return template;
    }

    private static DateTime PlannedStartDate(string notificationId, ShipmentPeriodType? period)
    {
        // Sample value: start in five days, but not before the consented shipment period.
        var start = DateTime.UtcNow.Date.AddDays(5);
        var first = period?.FirstStartDate?.Value?.ToDateTime().Date;
        var last = period?.LastStartDate?.Value?.ToDateTime().Date;

        if (first > start)
        {
            start = first.Value;
        }

        if (last < start)
        {
            throw new InvalidOperationException(
                $"The shipment period of notification {notificationId} ends on {last:yyyy-MM-dd}; " +
                "no movement can start in time.");
        }

        return start;
    }

    private static PackagingTypeBase PackagingFrom(PackagingType? notificationPackaging)
    {
        var packaging = new PackagingTypeBase
        {
            PackageDescription = notificationPackaging?.PackageDescription?.Clone(),
            SpecialHandlingRequirementIndicator =
                notificationPackaging?.SpecialHandlingRequirementIndicator?.Clone() ?? Bf(false)
        };

        if (notificationPackaging is { PackageTypeCode.Count: > 0 })
        {
            packaging.PackageTypeCode.Add(notificationPackaging.PackageTypeCode.Select(c => c.Clone()));
        }
        else
        {
            packaging.PackageTypeCode.Add(Sf(PackageTypeCode.Box.ToWire()));
        }

        return packaging;
    }

    private static WasteOperatorType Operator(string partyUuid, long operatorInternalId) => new()
    {
        PartyUuid = Sf(partyUuid),
        OperatorInternalId = I64(operatorInternalId),
        Contact = new ContactType
        {
            Name = Sf(ContactName),
            TelephoneCompleteNumber = Sf(ContactPhone),
            EmailUri = Sf(ContactEmail)
        }
    };

    private static ConcernedCountryType ConcernedCountry(string authorityPartyUuid) => new()
    {
        CountryId = Sf(CountryId),
        CompetentAuthority = new CompetentAuthorityType
        {
            PartyUuid = Sf(authorityPartyUuid),
            CompetentAuthorityId = Sf(CompetentAuthorityId)
        }
    };

    private static SignatureType Signature(string name, string email, string role)
    {
        var now = DateTime.UtcNow;
        return new SignatureType
        {
            Name = Sf(name),
            OrganizationRole = Sf(role),
            Timestamp = DateTimeUtc(now.Year, now.Month, now.Day, now.Hour, now.Minute, now.Second),
            EmailUri = Sf(email)
        };
    }

    private static DescriptionType Text(string text) => new()
    {
        Description = { new LanguageDescriptionType { LanguageId = Sf(Language), Text = Sf(text) } }
    };

    private static TimestampField Date(DateTime date) => DateUtc(date.Year, date.Month, date.Day);

    private static string NewPartyUuid() => Guid.NewGuid().ToString();
}

New transport paper for a notification: movement

dotnet run -- movement DE0126005589i

Announces a movement on a consented notification of your account: determines the next sequence number, builds the template from the notification (carrier, packaging, origin), stores it, sends it and waits for the receipt. Every announced movement counts against the number of shipments of the notification, even if it never takes place; cancel movements you do not need. If the consent was given only recently, run changes first so it reaches ZEDAL. Flow and stages: Movement (company role).

MovementSample.cs
namespace Zedal.Sdk.Samples;

/// <summary>
///     Announces one movement (shipment) on a consented notification: determine the next
///     sequence number, build the movement template, store it in ZEDAL and send it to DIWASS.
/// </summary>
/// <remarks>
///     Like the notification, the movement has no draft status via the API: it is ANNOUNCED
///     with the first send. Every announced movement uses up one of the shipments of the
///     notification, even if it never takes place - cancel movements you do not need.
/// </remarks>
public static class MovementSample
{
    public static async Task RunAsync(string notificationId)
    {
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            // 1) Load the notification from ZEDAL. The movement takes carrier, packaging and
            //    shipment origin from it. Updates from the authorities (such as the consent)
            //    only reach ZEDAL through the inbox import - see the 'changes' sample.
            var notification = client.DocumentHandler.CreateNotificationHandler();
            await notification.LoadFromZedalByNotificationId(notificationId);

            var information = notification.Notification.GetInformation();
            var consented = information?.Submission?.Base?.Submission
                ?? throw new InvalidOperationException(
                    $"Document {notification.ZedalDocumentId.Id} does not contain the submission of {notificationId}.");

            // Movements can only be announced on a consented notification.
            var status = information.NotificationStatus?.Value;
            Console.WriteLine($"Notification {notificationId}: status {status ?? "(unknown)"}");
            if (!string.IsNullOrEmpty(status) && status != "CONSENT")
            {
                throw new InvalidOperationException(
                    $"Notification {notificationId} is in status {status}, not CONSENT. " +
                    "If the consent was given recently, run the 'changes' sample to import it first.");
            }

            // 2) Next sequence number. ZEDAL derives it from the movements already stored; it is
            //    not reserved. Do not announce movements for the same notification in parallel,
            //    and never reuse a number, not even one of a cancelled movement.
            var sequenceNumber = await client.DocumentNumbers.GetNextMovementSequenceNumber(notificationId);
            Console.WriteLine($"Next sequence number: {sequenceNumber}");

            // 3) Build the template and let ZEDAL create the movement document.
            var handler = client.DocumentHandler.CreateMovementHandler();
            await handler.CreateMovementTemplate(
                DiwassSampleData.BuildMovementTemplate(notificationId, sequenceNumber, consented));

            if (handler.HasValidationErrors)
            {
                Console.WriteLine("Validation errors:");
                foreach (var error in handler.ValidationErrors)
                {
                    Console.WriteLine($"  {error.Path}: {error.Error}");
                }

                throw new InvalidOperationException("The movement template has validation errors; nothing was sent.");
            }

            // 4) Store in ZEDAL (transport paper area). The ZEDAL document id is the handle for
            //    all later stages of this movement; the MovementId only exists after the push.
            await handler.SaveToZedal();
            Console.WriteLine($"Saved in ZEDAL: document {handler.ZedalDocumentId.Id}");

            // 5) Send to DIWASS with an own deadline, then wait for the receipt. Movement stages
            //    took between 2.5 and 5 minutes in measurements, so the wait budget is generous.
            await handler.SendToDiwass(ZedalCallContext.Default.WithTimeout(180));
            Console.WriteLine($"Handed over to DIWASS, transfer log {handler.LastTransferLogId?.Id}. Waiting for the receipt ...");

            await handler.WaitForDiwassTransfer(
                timeout: TimeSpan.FromMinutes(6),
                pollInterval: TimeSpan.FromSeconds(5));

            // The MovementId is assigned by DIWASS and only visible after a reload.
            await handler.ReloadFromZedal();

            // MovementDocumentHandler exposes the MovementId through DiwassNotificationId,
            // format <NotificationId>.<6-digit sequence>MOV.
            Console.WriteLine($"Movement id:     {handler.DiwassNotificationId ?? "(not assigned yet)"}");
            Console.WriteLine($"ZEDAL document:  {handler.ZedalDocumentId.Id}");
            Console.WriteLine("Follow-up intents:");
            foreach (var intent in handler.FollowupIntents)
            {
                Console.WriteLine($"  {intent}");
            }

            Console.WriteLine();
            Console.WriteLine("Note: this movement now counts against the shipments of the notification. " +
                              "Cancel it if it will not take place.");
        }
        finally
        {
            await client.EndSession();
        }
    }
}

Data a transport paper takes over: transport-data

dotnet run -- transport-data DE0126005589i

Loads the notification and shows what a transport paper builds on: the parties with name and address from the DIWASS operator directory (the document only carries operator ids), the waste codes, the consented quantity and number of shipments, the movements already stored, the shipment period, the packaging and the shipment origin. A movement may only use what the authorities consented to; the movement sample takes carrier, packaging, shipment origin and start date from exactly these fields.

TransportDataSample.cs
using Infotech.Zedal.Documents.V1.Diwass.Directory.Operator.Base.V1;
using Infotech.Zedal.Documents.V1.Diwass.Waste.Model.V1;

namespace Zedal.Sdk.Samples;

/// <summary>
///     Shows the data of a consented notification that a transport paper (movement) builds on:
///     the parties with name and address, the waste, the consented quantity and number of
///     shipments, the shipment period, the packaging and the shipment origin.
/// </summary>
/// <remarks>
///     A movement may only use what the authorities consented to. The 'movement' sample takes
///     carrier, packaging, shipment origin and start date from exactly these fields
///     (<see cref="DiwassSampleData.BuildMovementTemplate"/>).
/// </remarks>
public static class TransportDataSample
{
    public static async Task RunAsync(string notificationId)
    {
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            var notification = client.DocumentHandler.CreateNotificationHandler();
            await notification.LoadFromZedalByNotificationId(notificationId);

            // The DIWASS state of the notification: null until DIWASS has answered.
            var information = notification.Notification.GetInformation();
            var submission = information?.Submission?.Base?.Submission
                ?? throw new InvalidOperationException(
                    $"Document {notification.ZedalDocumentId.Id} does not contain the submission of {notificationId}.");

            Console.WriteLine($"Notification {notificationId}, status {information.NotificationStatus?.Value ?? "(unknown)"}");

            // The document only carries operator ids. Names and addresses come from the DIWASS
            // operator directory, in one call for all parties.
            var parties = new List<WasteOperatorType?> { submission.Notifier, submission.Consignee };
            parties.AddRange(submission.WasteProducer.Select(p => p.Base));
            parties.AddRange(submission.Carrier.Select(c => c.Base));
            parties.AddRange(submission.RecoveryDisposalFacility.Select(f => f.Base?.Base));
            var ids = parties.Select(p => p?.OperatorInternalId?.Value).OfType<long>().Distinct().ToList();
            var directory = (await client.DiwassApi.OperatorApi.GetOperatorById(ids))
                .Where(o => o.Operator?.InternalId != null)
                .ToDictionary(o => o.Operator.InternalId.Value, o => o.Operator);

            Console.WriteLine();
            Console.WriteLine("Parties:");
            Console.WriteLine($"  Notifier       {Describe(submission.Notifier, directory)}");
            foreach (var producer in submission.WasteProducer)
            {
                Console.WriteLine($"  Waste producer {Describe(producer.Base, directory)}");
                Console.WriteLine($"                 site: {Text(producer.SiteDescription)}, process: {Text(producer.ProcessDescription)}");
            }

            // The carrier of a movement must be one of these carriers.
            foreach (var carrier in submission.Carrier)
            {
                Console.WriteLine($"  Carrier        {Describe(carrier.Base, directory)}, means of transport {carrier.MeansOfTransportCode?.Value}");
            }

            Console.WriteLine($"  Consignee      {Describe(submission.Consignee, directory)}");
            foreach (var facility in submission.RecoveryDisposalFacility)
            {
                Console.WriteLine($"  Facility       {Describe(facility.Base?.Base, directory)}, type {facility.FacilityTypeCode?.Value}");
            }

            Console.WriteLine();
            Console.WriteLine("Waste:");
            foreach (var code in submission.WasteClassification?.WasteTypeCode ?? Enumerable.Empty<WasteCodeType>())
            {
                Console.WriteLine($"  Code           {code.Code?.Value} ({code.ListId?.Value})");
            }

            Console.WriteLine($"  Description    {Text(submission.WasteDesignationDescription)}");
            Console.WriteLine($"  Operation      {string.Join(", ", submission.DisposalRecoveryOperations?.RecoveryDisposalTypeCode.Select(c => c.Value) ?? [])}");

            // Quantity and shipments. Each movement states its actual quantity; the movements
            // together must stay within the consented total and the intended number of shipments.
            var total = submission.TotalWasteMeasurement;
            Console.WriteLine();
            Console.WriteLine("Quantity and shipments:");
            Console.WriteLine($"  Consented      {total?.MassMeasure?.Tonnes?.Value} t {(total?.VolumeMeasure != null ? $"/ {total.VolumeMeasure.CubicMeters?.Value} m³" : "")}");
            Console.WriteLine($"  Shipments      {submission.ShipmentQuantity?.Value} intended");

            // Sequence numbers already taken by movements stored in ZEDAL, cancelled ones included.
            var next = await client.DocumentNumbers.GetNextMovementSequenceNumber(notificationId);
            Console.WriteLine($"  Movements      {next - 1} stored in ZEDAL, next sequence number {next}");

            // The planned start of a movement must lie within this period.
            Console.WriteLine();
            Console.WriteLine($"Shipment period  first start {Day(submission.ShipmentPeriod?.FirstStartDate?.Value)}, " +
                              $"last start {Day(submission.ShipmentPeriod?.LastStartDate?.Value)}");
            Console.WriteLine($"Packaging        {string.Join(", ", submission.Packaging?.PackageTypeCode.Select(c => c.Value) ?? [])}: " +
                              $"{Text(submission.Packaging?.PackageDescription)}");

            // The shipment origin points to a party of the document by its PartyUuid.
            var origin = submission.ShipmentOriginAddress?.PartyUuid?.Value;
            var originParty = parties.FirstOrDefault(p => p?.PartyUuid?.Value == origin);
            Console.WriteLine($"Shipment origin  {(originParty != null ? Describe(originParty, directory) : origin ?? "(not set)")}");
        }
        finally
        {
            await client.EndSession();
        }
    }

    private static string Describe(WasteOperatorType? party, IReadOnlyDictionary<long, OperatorBaseType> directory)
    {
        var id = party?.OperatorInternalId?.Value;
        if (id == null)
        {
            return "(no operator id)";
        }

        if (!directory.TryGetValue(id.Value, out var entry))
        {
            return $"operator {id} (not found in the directory)";
        }

        var address = (entry.OperatorAddress.FirstOrDefault(a => a.Main?.Value == true) ?? entry.OperatorAddress.FirstOrDefault())?.Address;
        var place = address == null
            ? ""
            : $", {address.Street?.Value}, {address.City?.PostalCode?.Value} {address.City?.Name?.Value}";
        return $"{entry.Name?.Value}{place} (operator {id})";
    }

    // Free texts carry one entry per language.
    private static string Text(DescriptionType? description) =>
        string.Join(" / ", description?.Description.Select(d => d.Text?.Value) ?? []);

    private static string Day(Google.Protobuf.WellKnownTypes.Timestamp? value) =>
        value?.ToDateTime().ToString("yyyy-MM-dd") ?? "(not set)";
}

Transport paper numbers: numbers

dotnet run -- numbers DE0126005589i 3   # next three movement sequence numbers
dotnet run -- numbers bgs               # one consignment note number (BMU)
dotnet run -- numbers uns 2             # two acceptance note numbers (BMU)

ZEDAL derives movement sequence numbers from the movements stored, and they are not reserved: two calls without a stored movement in between return the same numbers. Consignment note (Begleitschein) and acceptance note (Übernahmeschein) numbers are assigned by the server for good, from the account’s quota, even if no document is ever created with them; fetch them only when you create the document. The sample fetches at most ten at a time.

NumbersSample.cs
using Infotech.Zedal.Core.V1.Api;

namespace Zedal.Sdk.Samples;

/// <summary>
///     Numbers for transport papers: the next sequence numbers of DIWASS movements on a
///     notification, and consignment note and acceptance note numbers of the German waste
///     record procedure (BMU).
/// </summary>
/// <remarks>
///     The two kinds behave differently. A movement sequence number is derived from the
///     movements stored in ZEDAL and not reserved: two calls without a stored movement in
///     between return the same number. A BMU number is assigned by the server for good and
///     counts against the account's quota, even if no document is ever created with it.
/// </remarks>
public static class NumbersSample
{
    public static async Task RunMovementAsync(string notificationId, int count)
    {
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            // Next free sequence numbers of the notification, in one query. They are a proposal:
            // a number is taken only once a movement with it is stored in ZEDAL. Do not announce
            // movements for the same notification in parallel, and never reuse a number, not even
            // one of a cancelled movement.
            var numbers = await client.DocumentNumbers.GetNextMovementSequenceNumbers(notificationId, count);
            Console.WriteLine($"Next sequence numbers for {notificationId}: {string.Join(", ", numbers)}");

            // DIWASS assigns the movement id on the first push, in the format
            // <NotificationId>.<6-digit sequence number>MOV.
            Console.WriteLine($"Movement id once DIWASS has accepted the first one: {notificationId}.{numbers[0]:D6}MOV");
        }
        finally
        {
            await client.EndSession();
        }
    }

    public static async Task RunBmuAsync(string kind, int count)
    {
        var type = kind == "uns"
            ? FetchDocumentNumbersRequest.Types.NumberType.BmuUns
            : FetchDocumentNumbersRequest.Types.NumberType.BmuBgs;

        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            // The server assigns these numbers for good, from the account's quota. Fetch a number
            // when you are about to create the document and store it with the document right away;
            // do not fetch numbers in advance.
            var numbers = (await client.CoreApi.Common.FetchNumbers(count, type)).ToList();

            // Consignment note: "1" and 13 digits; acceptance note: "2" and 13 digits.
            Console.WriteLine(kind == "uns" ? "Acceptance note numbers:" : "Consignment note numbers:");
            foreach (var number in numbers)
            {
                Console.WriteLine($"  {number}");
            }
        }
        finally
        {
            await client.EndSession();
        }
    }
}

Fetching changes: changes

dotnet run -- changes      # last 7 days
dotnet run -- changes 2    # last 2 days

Lists the DIWASS events of the period (only with the WSSE variables), then imports pending notifications and movements from the ZEDAL inbox and prints the import report with the status of each document. Changes made by others, such as authority decisions, requests for information or movement stages of other parties, reach your documents only through this import; see Calls and synchronization.

ChangesSample.cs
using Infotech.Zedal.Documents.V1.Diwass.Waste.Retrieval.V1;
using Infotech.Zedal.Documents.V1.Zewass.V1;
using Zedal.Sdk.DocumentHandler.InboxSync;
using static Zedal.Sdk.Protobuf.ProtoFields;

namespace Zedal.Sdk.Samples;

/// <summary>
///     One synchronization cycle: list the DIWASS events of the last days, then take the
///     incoming document versions over from the ZEDAL inbox.
/// </summary>
/// <remarks>
///     Changes made by others (authority decisions, requests for information, movement
///     stages of other parties) reach your ZEDAL documents only through the inbox import.
///     Polling a document without importing never shows them. Synchronize at least every
///     12 hours and after each of your own submissions, and fetch only what changed.
/// </remarks>
public static class ChangesSample
{
    // ZEDAL document types of DIWASS documents in the inbox.
    private const string NotificationType = "N";   // notification
    private const string MovementType = "VB";      // movement (Versandbegleitpapier)

    public static async Task RunAsync(int days)
    {
        var wsse = SampleSettings.ReadDiwassWsseCredentials();
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            await ListEventsAsync(client, wsse, days);
            await ImportInboxAsync(client);
        }
        finally
        {
            await client.EndSession();
        }
    }

    /// <summary>Step 1: the event list tells you which document changed in which way.</summary>
    private static async Task ListEventsAsync(IZedalClient client, DiwassWsseCredentials? wsse, int days)
    {
        Console.WriteLine($"DIWASS events of the last {days} day(s):");

        // The DIWASS retrieval API needs your own DIWASS WSSE credentials; there is no fallback.
        if (wsse == null)
        {
            Console.WriteLine($"  skipped: set {SampleSettings.DiwassWsseUserVariable} and " +
                              $"{SampleSettings.DiwassWssePasswordVariable} to query the event list.");
            Console.WriteLine();
            return;
        }

        client.DiwassApi.SetDiwassCredentials(wsse.User, wsse.Password);

        // In a real sync start at the last successful sync, with some overlap. DIWASS calls
        // often take longer than the 30 s client default, hence the own deadline.
        var to = DateTime.UtcNow;
        var from = to.AddDays(-days);
        var response = await client.DiwassApi.RetrievalApi.LookupEventList(
            new LookupEventListRequest
            {
                DateFrom = DateTimeUtc(from.Year, from.Month, from.Day, from.Hour, from.Minute, from.Second),
                DateTo = DateTimeUtc(to.Year, to.Month, to.Day, to.Hour, to.Minute, to.Second)
            },
            ZedalCallContext.Default.WithTimeout(120));

        var events = response.NotificationEventList?.WasteEvent;
        if (events == null || events.Count == 0)
        {
            Console.WriteLine("  (no events)");
        }
        else
        {
            foreach (var e in events.OrderBy(e => e.EventDate?.Value?.ToDateTime()))
            {
                // The code is a token such as NEW_DECISION or ANNOUNCE_MOVEMENT. New codes can
                // appear at any time: treat unknown ones as "check manually", not as an error.
                Console.WriteLine($"  {e.EventDate?.Value?.ToDateTime():yyyy-MM-dd HH:mm} UTC  " +
                                  $"{e.Code?.Value,-36} {e.Identifier?.EventIdentifier?.Value}  {e.Description?.Value}");
            }
        }

        Console.WriteLine();
    }

    /// <summary>
    ///     Step 2: take the incoming versions over from the inbox. Notifications live in the
    ///     file area (one file per notification), movements in the transport paper area, so
    ///     each type has its own import. Using the wrong import for a type is harmful.
    /// </summary>
    private static async Task ImportInboxAsync(IZedalClient client)
    {
        var importer = client.DocumentHandler.CreateInboxImporter();

        // Look first: listing does not change anything.
        var pending = await importer.ListPendingAsync(new[] { NotificationType, MovementType });
        Console.WriteLine($"Pending DIWASS documents in the inbox: {pending.Count}");
        foreach (var item in pending)
        {
            Console.WriteLine($"  {item.InboxDocId.Id}  type {item.DocumentType}");
        }

        if (pending.Count == 0)
        {
            return;
        }

        // ZEDAL matches each entry to the existing document itself and stores it as a new
        // version (or creates the document). Successfully imported entries are removed from
        // the inbox; failed ones stay there and are listed in the report.
        if (pending.Any(i => i.DocumentType == NotificationType))
        {
            var report = await importer.ImportToFileAreaAsync(documentTypes: new[] { NotificationType });
            PrintReport("Notifications", report);
            foreach (var item in report.Imported)
            {
                var notification = client.DocumentHandler.CreateNotificationHandler();
                await notification.LoadFromZedal(item.LocalDocId);
                PrintDocument(notification.DiwassNotificationId, notification.FollowupIntents,
                    notification.Notification.GetInformation()?.NotificationStatus?.Value);
            }
        }

        if (pending.Any(i => i.DocumentType == MovementType))
        {
            var report = await importer.ImportAsTransportAsync(documentTypes: new[] { MovementType });
            PrintReport("Movements", report);
            foreach (var item in report.Imported)
            {
                var movement = client.DocumentHandler.CreateMovementHandler();
                await movement.LoadFromZedal(item.LocalDocId);
                PrintDocument(movement.DiwassNotificationId, movement.FollowupIntents,
                    movement.Movement.GetInformation()?.Announcement?.MovementStatus?.Value);
            }
        }
    }

    private static void PrintReport(string title, InboxImportReport report)
    {
        Console.WriteLine();
        Console.WriteLine($"{title}: {report.Imported.Count} imported, {report.Failed.Count} failed");
        foreach (var item in report.Imported)
        {
            Console.WriteLine($"  imported  inbox {item.InboxDocId.Id} -> document {item.LocalDocId.Id}");
        }

        foreach (var failure in report.Failed)
        {
            Console.WriteLine($"  failed    inbox {failure.InboxDocId.Id}: {failure.Error}");
        }
    }

    private static void PrintDocument(string? diwassId, IEnumerable<DiwassIntents> intents, string? status = null)
    {
        // Upsert your local data by the ZEDAL document id or the DIWASS id - the same document
        // comes back with every later change.
        var list = string.Join(", ", intents);
        var state = status == null ? "" : $" status {status},";
        Console.WriteLine($"    {diwassId ?? "(no DIWASS id yet)"}:{state} next possible steps [{list}]");
    }
}

Changed since: changed-since

dotnet run -- changed-since                        # last 24 hours
dotnet run -- changed-since 2026-10-13T08:00:00Z

Lists the documents in the transport paper area (movements, consignment notes) and in the file area (notifications) whose last change is at or after the given point in time (UTC), oldest first. Changes made by others show up only after they were taken over from the inbox (changes). Querying with the time of the previous run shows each change once.

ChangedSinceSample.cs
using Google.Protobuf.WellKnownTypes;
using Infotech.Zedal.Query.V1;

namespace Zedal.Sdk.Samples;

/// <summary>
///     Lists the documents of the account that changed since a point in time: the transport
///     paper area (movements, consignment notes) and the file area (notifications).
/// </summary>
/// <remarks>
///     Changes made by others first arrive in the inbox. They show up here only after they
///     were taken over into the inventory - see the 'changes' sample. A poll every few minutes
///     with the time of the previous run picks up every change once.
/// </remarks>
public static class ChangedSinceSample
{
    private const int PageSize = 100;
    private const string LastChanged = "doc.last_changed";

    public static async Task RunAsync(DateTime sinceUtc)
    {
        var client = await SampleSettings.CreateLoggedInClientAsync();
        try
        {
            Console.WriteLine($"Documents changed since {sinceUtc:yyyy-MM-dd HH:mm:ss} UTC");

            Console.WriteLine();
            Console.WriteLine("Transport paper area:");
            var transport = 0;
            for (var start = 0; ; start += PageSize)
            {
                var page = await client.CoreApi.Query.ListTransportDocuments(ChangedSince(sinceUtc, start));
                foreach (var entry in page.Entries)
                {
                    PrintEntry(entry.Doc, entry.Cancelled ? "cancelled" : null);
                }

                transport += page.Entries.Count;
                if (page.Entries.Count < PageSize || transport >= page.Total)
                {
                    break;
                }
            }

            Console.WriteLine();
            Console.WriteLine("File area:");
            var fileArea = 0;
            for (var start = 0; ; start += PageSize)
            {
                var page = await client.CoreApi.Query.ListFileAreaDocuments(ChangedSince(sinceUtc, start));
                foreach (var entry in page.Entries)
                {
                    PrintEntry(entry.Doc, null);
                }

                fileArea += page.Entries.Count;
                if (page.Entries.Count < PageSize || fileArea >= page.Total)
                {
                    break;
                }
            }

            Console.WriteLine();
            Console.WriteLine($"{transport} transport documents, {fileArea} file area documents.");
        }
        finally
        {
            await client.EndSession();
        }
    }

    // Filter on the time of the last change, oldest first; the operand has the type of the
    // field (doc.last_changed is a UTC timestamp). Pages of PageSize entries.
    private static QueryParameter ChangedSince(DateTime sinceUtc, int pageStart) => new()
    {
        Filter =
        {
            new FilterCondition
            {
                Path = LastChanged,
                Op = Operator.GreaterOrEqualThan,
                Operands = { new Variant { Utc = Timestamp.FromDateTime(sinceUtc) } }
            }
        },
        Sort = { new Sort { Path = LastChanged, Direction = SortDirection.Asc } },
        PageStart = pageStart,
        PageSize = PageSize
    };

    private static void PrintEntry(Infotech.Zedal.Core.V1.Api.DocumentEntry doc, string? note)
    {
        var changed = doc.LastChanged?.ToDateTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "?";
        var subject = !string.IsNullOrEmpty(doc.NotificationId)
            ? doc.NotificationId + (string.IsNullOrEmpty(doc.SequenceNumber) ? "" : $" #{doc.SequenceNumber}")
            : doc.WasteProducer?.Name1 ?? "";
        Console.WriteLine($"  {changed}  {doc.DocumentType,-4} {doc.DocumentId?.Id}  {subject}{(note != null ? $"  ({note})" : "")}");
    }
}

Configuration: SampleSettings.cs

Reads the environment variables, holds the app token of the samples and creates the logged-in client that all samples with login use.

SampleSettings.cs
using Zedal.Sdk.Impl;
using Zedal.Sdk.Jwt;

namespace Zedal.Sdk.Samples;

/// <summary>Login data for <c>CreateSession</c>, read from the environment.</summary>
public sealed record LoginSettings(int ProviderId, string Subscriber, string Account, string Password);

/// <summary>EORI numbers of the other parties of the sample notification.</summary>
public sealed record NotificationPartyEoris(string Carrier, string Consignee, string Facility);

/// <summary>DIWASS WSSE credentials, only needed for the DIWASS retrieval API.</summary>
public sealed record DiwassWsseCredentials(string User, string Password);

/// <summary>Thrown when the sample configuration is missing or invalid (exit code 2).</summary>
public sealed class SampleConfigurationException(string message) : Exception(message);

/// <summary>
///     Configuration of the samples. Everything that differs between users comes from
///     environment variables, so no credentials end up in the source code.
/// </summary>
public static class SampleSettings
{
    // App token of the SDK samples (scope "core"). It identifies this sample application.
    // Your own integration gets its own app token from ZEDAL support; do not reuse this one.
    public const string SamplesAppToken =
        "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3OTE4MjA3NjUsInNjb3BlIjpbImNvcmUiXSwic3ViIjoiZGUuemVkYWwuc2RrLnNhbXBsZXMiLCJ0dHlwIjoiQVBQIn0.pnSBSE2TWihKymofafSBs-PFK3J2j_D0meQTVWl_LDLqsr3-9I0dMUWqV_CUrCw7Y2IIltMsGs4h93Gx6lkMAQ";

    // Client name and version go into every call and show up in the server logs.
    // Use stable values for your own application; they make support requests much faster.
    public const string ClientName = "zedal-sdk-samples";

    public static string ClientVersion { get; } =
        typeof(SampleSettings).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";

    public const string TargetVariable = "ZEDAL_TARGET";
    public const string ProviderIdVariable = "ZEDAL_PROVIDER_ID";
    public const string SubscriberVariable = "ZEDAL_SUBSCRIBER";
    public const string AccountVariable = "ZEDAL_ACCOUNT";
    public const string PasswordVariable = "ZEDAL_PASSWORD";
    public const string RefreshTokenFileVariable = "ZEDAL_REFRESH_TOKEN_FILE";
    public const string CarrierEoriVariable = "ZEDAL_CARRIER_EORI";
    public const string ConsigneeEoriVariable = "ZEDAL_CONSIGNEE_EORI";
    public const string FacilityEoriVariable = "ZEDAL_FACILITY_EORI";
    public const string DiwassWsseUserVariable = "ZEDAL_DIWASS_WSSE_USER";
    public const string DiwassWssePasswordVariable = "ZEDAL_DIWASS_WSSE_PASSWORD";

    public const string DefaultRefreshTokenFile = "zedal-refresh-token.txt";

    /// <summary>Target environment, default <see cref="TargetSystem.Test" />. Develop against Test.</summary>
    public static TargetSystem ReadTarget()
    {
        var raw = Optional(TargetVariable);
        if (raw == null)
        {
            return TargetSystem.Test;
        }

        // Accept the names only, not the numeric enum values.
        if (!int.TryParse(raw, out _)
            && Enum.TryParse<TargetSystem>(raw.Trim(), ignoreCase: true, out var target))
        {
            return target;
        }

        throw new SampleConfigurationException(
            $"{TargetVariable}='{raw}' is not valid. Use Test, Preview, Production or Development.");
    }

    /// <summary>Reads the login data. Reports all missing variables at once.</summary>
    public static LoginSettings ReadLogin()
    {
        RequireAll(ProviderIdVariable, SubscriberVariable, PasswordVariable);

        var rawProviderId = Optional(ProviderIdVariable)!;
        if (!int.TryParse(rawProviderId, out var providerId) || providerId <= 0)
        {
            throw new SampleConfigurationException(
                $"{ProviderIdVariable}='{rawProviderId}' is not a valid provider id. " +
                "Run the 'providers' sample to list the providers of the target environment.");
        }

        // The subscriber is the actual login. The account below it is optional:
        // without one you log in as the subscriber and leave the account empty.
        return new LoginSettings(
            providerId,
            Optional(SubscriberVariable)!,
            Optional(AccountVariable) ?? string.Empty,
            Optional(PasswordVariable)!);
    }

    /// <summary>File the login sample stores the refresh token in.</summary>
    public static string RefreshTokenFile =>
        Path.GetFullPath(Optional(RefreshTokenFileVariable) ?? DefaultRefreshTokenFile);

    public static NotificationPartyEoris ReadNotificationPartyEoris()
    {
        RequireAll(CarrierEoriVariable, ConsigneeEoriVariable, FacilityEoriVariable);
        return new NotificationPartyEoris(
            Optional(CarrierEoriVariable)!,
            Optional(ConsigneeEoriVariable)!,
            Optional(FacilityEoriVariable)!);
    }

    /// <summary>Optional DIWASS WSSE credentials; <c>null</c> if not configured.</summary>
    public static DiwassWsseCredentials? ReadDiwassWsseCredentials()
    {
        var user = Optional(DiwassWsseUserVariable);
        var password = Optional(DiwassWssePasswordVariable);
        if (user == null && password == null)
        {
            return null;
        }

        RequireAll(DiwassWsseUserVariable, DiwassWssePasswordVariable);
        return new DiwassWsseCredentials(user!, password!);
    }

    /// <summary>Client for the configured target environment, without a session.</summary>
    public static IZedalClient CreateClient(TargetSystem target) =>
        ZedalClientFactory.Default.CreateClient(target, ClientName, ClientVersion);

    /// <summary>
    ///     Creates a client and logs in with separate fields (provider, subscriber, account,
    ///     password). Prefer this over the combined user id string: that one is split at
    ///     '-', '@' and ':' and breaks as soon as the password contains one of them.
    /// </summary>
    /// <param name="onTokensChanged">
    ///     Optional rotation callback. It is registered before the login because the login
    ///     itself already rotates the refresh token.
    /// </param>
    public static async Task<IZedalClient> CreateLoggedInClientAsync(
        Action<(AccessToken accessToken, RefreshToken refreshToken)>? onTokensChanged = null)
    {
        // Read everything first, so a missing variable fails before any network call.
        var target = ReadTarget();
        var login = ReadLogin();

        var client = CreateClient(target);
        if (onTokensChanged != null)
        {
            client.SetAccessTokenChanged(onTokensChanged);
        }

        await client.CreateSession(
            providerId: login.ProviderId,
            subscriberId: login.Subscriber,
            accountId: login.Account,
            password: login.Password,
            appToken: new AppToken(SamplesAppToken));

        Console.WriteLine($"Logged in to {target} as {client.CoreApi.Auth.AccessToken.GetUserId()}.");
        return client;
    }

    private static string? Optional(string name)
    {
        // Values are used as given (a password may contain spaces); blank counts as missing.
        var value = Environment.GetEnvironmentVariable(name);
        return string.IsNullOrWhiteSpace(value) ? null : value;
    }

    private static void RequireAll(params string[] names)
    {
        var missing = names.Where(n => Optional(n) == null).ToList();
        if (missing.Count > 0)
        {
            throw new SampleConfigurationException(
                $"Missing environment variable(s): {string.Join(", ", missing)}. " +
                "Run 'help' for the list of variables each sample needs.");
        }
    }
}

Before you go live

  • Calls one after another. DIWASS requires sequential calls per user; parallel calls can get the access blocked.
  • No retry on data errors. Fix the data first; retry technical errors only after a pause.
  • Time budgets. The client default of 30 seconds is too short for DIWASS; the samples set their own deadlines.
  • Synchronize regularly. After each of your own submissions and at least every 12 hours, with the event list and the inbox import.