Kontent.ai Sync SDK for .NET

August 12, 2026 · View on GitHub

NuGet Downloads

Official .NET SDK for the Kontent.ai Sync API v2.

Use this SDK to initialize sync and process delta updates for content items, content types, languages, and taxonomies.

Important

This SDK targets Sync API v2 exclusively. Sync API v1 is deprecated and not supported.

Installation

dotnet add package Kontent.Ai.Sync

Quick Start

1. Register the sync client

using Kontent.Ai.Sync;

services.AddSyncClient(options =>
{
    options.EnvironmentId = "your-environment-id";
    options.ApiMode = ApiMode.Preview;
    options.ApiKey = "your-preview-api-key";
});

2. Initialize sync

public sealed class SyncService(ISyncClient syncClient)
{
    public async Task<string?> InitializeAsync(CancellationToken cancellationToken = default)
    {
        var result = await syncClient.InitializeSyncAsync(cancellationToken);

        if (!result.IsSuccess)
        {
            throw new InvalidOperationException(result.Error?.Message ?? "Sync init failed.");
        }

        // Persist and reuse this token for subsequent delta calls.
        return result.SyncToken;
    }
}

3. Fetch delta updates

var deltaResult = await syncClient.GetDeltaAsync(syncToken, cancellationToken);

if (!deltaResult.IsSuccess)
{
    Console.WriteLine($"Sync failed: {deltaResult.Error?.Message} (request {deltaResult.Error?.RequestId})");
    return;
}

var delta = deltaResult.Value;
foreach (var item in delta.Items)
{
    // Data is null when the entry only records that something was deleted.
    Console.WriteLine($"{item.Timestamp:u}  {item.ChangeType}  {item.Data?.System.Codename}");
}

await SaveSyncTokenAsync(deltaResult.SyncToken);

4. Walk every page

EnumerateDeltaAsync keeps requesting until the API reports an empty response, which is how it says you have caught up. Requests are made as you iterate, so bound the walk with Take or by breaking out of the loop — nothing is fetched ahead of you.

var token = syncToken;

await foreach (var page in syncClient.EnumerateDeltaAsync(syncToken, cancellationToken))
{
    if (!page.IsSuccess)
    {
        Console.WriteLine($"Sync failed: {page.Error?.Message}");
        break;
    }

    foreach (var item in page.Value.Items)
    {
        Console.WriteLine($"{item.Timestamp:u}  {item.ChangeType}  {item.Data?.System.Codename}");
    }

    token = page.SyncToken;
}

// An empty sequence means there was nothing new, and the token you passed in is still current.
await SaveSyncTokenAsync(token);

What a delta page contains

Each of the four collections holds SyncChange<TData> entries sharing one envelope — what changed, when, and the metadata:

Member
ChangeTypeChanged or Deleted
Timestampwhen the change occurred in the Delivery API, UTC
Datathe entity's metadata; null when the entry carries none

The payload differs per collection, because the API's does:

CollectionDataData.System
ItemsSyncItemDataid, collection, name, codename, language, type, last modified, workflow, workflow step
TypesSyncTypeDataid, name, codename, last modified
TaxonomiesSyncTaxonomyDataid, name, codename, last modified
LanguagesSyncLanguageDataid, name, codename

Workflow and WorkflowStep are absent for components. A language carries no last-modified stamp, which is why the four payloads are separate types rather than one.

Configuration

API modes

// Public Production API
services.AddSyncClient(o =>
{
    o.EnvironmentId = "your-environment-id";
    o.ApiMode = ApiMode.Public;
});

// Preview API
services.AddSyncClient(o =>
{
    o.EnvironmentId = "your-environment-id";
    o.ApiMode = ApiMode.Preview;
    o.ApiKey = "preview-api-key";
});

// Secure Production API
services.AddSyncClient(o =>
{
    o.EnvironmentId = "your-environment-id";
    o.ApiMode = ApiMode.Secure;
    o.ApiKey = "secure-access-api-key";
});

Options builder

services.AddSyncClient(builder => builder
    .WithEnvironmentId("your-environment-id")
    .UsePreviewApi("preview-api-key")
    .DisableRetryPolicy()
    .Build());

Configuration binding

appsettings.json:

{
  "SyncOptions": {
    "EnvironmentId": "your-environment-id",
    "ApiMode": "Preview",
    "ApiKey": "preview-api-key",
    "EnableResilience": true
  }
}

Registration — pass the whole configuration and let the SDK find its section, or hand it the section directly:

services.AddSyncClient(configuration);                              // binds "SyncOptions"
services.AddSyncClient(configuration, "MySyncSection");             // or a differently-named section
services.AddSyncClient(configuration.GetSection("SyncOptions"));    // or the section itself

The default section name is available as SyncOptions.DefaultConfigurationSectionName, so tooling that resolves the SDK's configuration from the same sources does not have to hard-code it.

Binding this way is change-token backed: edits to the underlying source are picked up through IOptionsMonitor<SyncOptions> without rebuilding the container. All configuration overloads accept the same optional configureHttpClient / configureResilience hooks as the action-based ones:

services.AddSyncClient(
    configuration,
    configureResilience: builder => builder.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 5 }));

The default pipeline bounds each attempt at 30 seconds and then retries, which can legitimately outlast HttpClient's own 100-second ceiling on the whole call - retries and backoff included - so that ceiling is lifted while the default pipeline is the one installed. Set EnableResilience = false, or replace the pipeline through configureResilience, and the ceiling applies again: nothing else would bound the request. Raise it with configureHttpClient, which runs after the SDK's own configuration.

Options from other registered services

When the options depend on something else in the container — a secret store, a tenant resolver — use the overload that hands you the IServiceProvider:

services.AddSyncClient((sp, options) =>
{
    var secrets = sp.GetRequiredService<ISecretStore>();
    options.EnvironmentId = secrets.EnvironmentId;
    options.ApiKey = secrets.SyncApiKey;
});

Standalone client (without DI)

For console apps, Azure Functions isolated workers, scripts, or tests where a full DI container is not available, use SyncClientBuilder to construct a client directly. The builder spins up a private service collection internally; the returned client owns its dependencies and must be disposed.

using Kontent.Ai.Sync.Configuration;

await using var client = SyncClientBuilder
    .WithOptions(opts => opts
        .WithEnvironmentId("your-environment-id")
        .UsePreviewApi("preview-api-key")
        .Build())
    .Build();

var result = await client.InitializeSyncAsync();

Optional configuration:

await using var client = SyncClientBuilder
    .WithOptions(opts => opts.WithEnvironmentId("env-id").UseProductionApi().Build())
    .WithLoggerFactory(loggerFactory)
    .WithResilience(builder => builder.AddRetry(new HttpRetryStrategyOptions { MaxRetryAttempts = 5 }))
    .Build();

The returned client is thread-safe and should be used as a singleton for the lifetime of your application. Each Build() call creates an independent client that owns its own HttpClient, which is why it is disposable — dispose it and that transport is released. A client resolved from a container is owned by the container instead, so there is nothing for you to dispose there.

Named Clients

services.AddSyncClient("production", o =>
{
    o.EnvironmentId = "prod-environment-id";
    o.ApiMode = ApiMode.Public;
});

services.AddSyncClient("preview", o =>
{
    o.EnvironmentId = "preview-environment-id";
    o.ApiMode = ApiMode.Preview;
    o.ApiKey = "preview-api-key";
});

public sealed class MultiEnvironmentService(ISyncClientFactory factory)
{
    public ISyncClient ProductionClient => factory.Get("production");
    public ISyncClient PreviewClient => factory.Get("preview");
}

Every registration form has a named counterpart, so named clients can bind from configuration too:

services.AddSyncClient("production", configuration, "Sync:Production");
services.AddSyncClient("preview", configuration.GetSection("Sync:Preview"));

Error Handling

Every call returns a result rather than throwing. ISyncResult carries the outcome — success, error, status, the continuation token — and ISyncResult<T> adds Value for the calls that return content. InitializeSyncAsync returns the non-generic form, because initialization produces a token rather than content; GetDeltaAsync and EnumerateDeltaAsync return the generic one.

var result = await syncClient.GetDeltaAsync(syncToken);

if (!result.IsSuccess)
{
    Console.WriteLine(result.Error?.Message);
    Console.WriteLine(result.Error?.RequestId);
    Console.WriteLine(result.Error?.ErrorCode);
    Console.WriteLine(result.StatusCode);
    return;
}

Important fields:

  • ISyncResult.StatusCode (HttpStatusCode)
  • ISyncResult.ResponseHeaders
  • ISyncResult.RequestUrl
  • ISyncResult.SyncToken
  • ISyncResult<T>.Value — the delta payload, on the calls that return content
  • IError.Message
  • IError.RequestId
  • IError.ErrorCode / IError.SpecificCode
  • IError.Exception

Token Persistence

The SDK does not persist sync tokens. Store SyncToken after every successful call and pass it into the next GetDeltaAsync or EnumerateDeltaAsync call. Every successful response carries one, so it is never null on a successful result; a response without it fails rather than returning a result you could not continue from.

Where you store it during a walk is a choice. Saving once after the loop means a crash part-way through reprocesses from the previous token — some changes arrive twice, none are missed. Saving after each page resumes closer to where you stopped. Saving before processing a page is the one variant that can lose work.

Source Tracking (for Tool Authors)

Every request the SDK sends carries two tracking headers:

  • X-KC-SDKID — identifies this SDK. Always set to nuget.org;Kontent.Ai.Sync;<version>. You can't configure it.
  • X-KC-SOURCE — identifies a library built on top of the SDK. Only set when a caller assembly opts in via SyncSourceTrackingHeaderAttribute. Omitted otherwise.

End-user applications don't need to do anything. This section only matters if you're publishing a library that wraps the Sync SDK.

If you are, add one of the following at assembly level (typically in AssemblyInfo.cs or a top-level using file). At request time the SDK walks the call stack, locates your assembly, reads the attribute, and composes the header value.

1. Read name and version from the assembly (most common):

[assembly: SyncSourceTrackingHeaderAttribute]

Header becomes <AssemblyName>;<AssemblyInformationalVersion>.

2. Override the name, keep version from the assembly:

[assembly: SyncSourceTrackingHeaderAttribute("Acme.Kontent.Ai.AwesomeTool")]

Useful when your NuGet package ID differs from your assembly name.

3. Hard-code everything:

[assembly: SyncSourceTrackingHeaderAttribute("Acme.Kontent.Ai.AwesomeTool", 1, 2, 3, "beta")]

Useful when you want to pin the reported version independent of assembly metadata.

Upgrade Guide

  • Coming from 1.0 — see the 1.0 → 2.0 upgrade guide. The two changes that need real work are the .NET 10 move and paging, which is now a stream you enumerate.
  • Coming from the sync methods that used to live in Kontent.Ai.Delivery — those were removed in Delivery 19.0. Move to Kontent.Ai.Sync by following its Quick Start: sync has its own client, and every call returns ISyncResult<T> rather than throwing.

Contributing

Contributions are welcome. Use GitHub Issues for bug reports and feature requests, and open pull requests in this repository for code contributions.

License

Licensed under the MIT License. See LICENSE.md for details.