Kontent.ai Sync SDK for .NET
August 12, 2026 · View on GitHub
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 | |
|---|---|
ChangeType | Changed or Deleted |
Timestamp | when the change occurred in the Delivery API, UTC |
Data | the entity's metadata; null when the entry carries none |
The payload differs per collection, because the API's does:
| Collection | Data | Data.System |
|---|---|---|
Items | SyncItemData | id, collection, name, codename, language, type, last modified, workflow, workflow step |
Types | SyncTypeData | id, name, codename, last modified |
Taxonomies | SyncTaxonomyData | id, name, codename, last modified |
Languages | SyncLanguageData | id, 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.ResponseHeadersISyncResult.RequestUrlISyncResult.SyncTokenISyncResult<T>.Value— the delta payload, on the calls that return contentIError.MessageIError.RequestIdIError.ErrorCode/IError.SpecificCodeIError.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 tonuget.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 viaSyncSourceTrackingHeaderAttribute. 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 toKontent.Ai.Syncby following its Quick Start: sync has its own client, and every call returnsISyncResult<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.