SFMCSDK.Net
4.0.1.2
dotnet add package SFMCSDK.Net --version 4.0.1.2
NuGet\Install-Package SFMCSDK.Net -Version 4.0.1.2
<PackageReference Include="SFMCSDK.Net" Version="4.0.1.2" />
<PackageVersion Include="SFMCSDK.Net" Version="4.0.1.2" />
<PackageReference Include="SFMCSDK.Net" />
paket add SFMCSDK.Net --version 4.0.1.2
#r "nuget: SFMCSDK.Net, 4.0.1.2"
#:package SFMCSDK.Net@4.0.1.2
#addin nuget:?package=SFMCSDK.Net&version=4.0.1.2
#tool nuget:?package=SFMCSDK.Net&version=4.0.1.2
SFMCSDK.Net
One Salesforce Marketing Cloud SFMC SDK API for .NET. Awaitable initialization, identity (contact key and attributes) and custom event tracking on Android and iOS, written once in shared code — over the SFMCSDK.Net.Android and SFMCSDK.Net.iOS bindings.
dotnet add package SFMCSDK.Net
using SFMCSDK.Net;
var sdk = new SfmcSdkClient(); // one per process - register it as a DI singleton
await sdk.InitializeAsync(new SfmcSdkOptions { LogLevel = SfmcLogLevel.Debug });
sdk.Identity.SetProfileId("contact-key");
sdk.Identity.SetAttribute("plan", "pro");
sdk.TrackCustomEvent("checkout_started", new Dictionary<string, string>
{
["cart_size"] = "3",
});
That is the whole shared-code surface, and it is the same call sites on both platforms — no
#if, no per-platform adapter.
Contents
- Why there is a cross-platform layer
- What the façade does and does not hide
- Packages and versions
- Installing
- Usage notes
- How this repository works
- Building locally
- Tests
- Upgrading
- Releasing
- Troubleshooting
- Licence
Why there is a cross-platform layer
The two platform bindings are faithful projections of what Salesforce ships, and consequently share almost no shape. The same three operations, raw:
| Operation | Android (Com.Salesforce.Marketingcloud.Sfmcsdk) |
iOS (SFMCSDK) |
|---|---|---|
| Initialize | SFMCSdk.Configure(context, config, status => …) — needs a Context, reports one terminal status through a Kotlin Function1 |
SFMCSdk.InitializeSdk(config, statuses => …) — all static, reports per-module statuses through a block |
| Identity | sdk.Identity = sdk.Identity.NewBuilder().SetProfileId(x).Build() — an immutable record on an instance that RequestSdk hands out asynchronously |
SFMCSdk.Identity.Edit(m => { m.ProfileId = x; return m; }) — a static object edited in place through a modifier closure |
| Track | SFMCSdk.Track(EventManager.CustomEvent(name, attrs)) — attributes are IDictionary<string, Java.Lang.Object> |
SFMCSdk.Track(new SFMCSdkCustomEvent(name, attrs)) — attributes are NSDictionary<NSString, NSObject> |
Writing that twice per app is the tax this package removes. It also files off the sharp edges the raw surfaces leave: a custom event whose name the native SDK rejects throws here (Android's factory returns null and the event silently vanishes; iOS's initializer returns nil), and an initialization callback that never arrives times out with an exception naming the operation instead of hanging the app's startup await forever.
And one edge is upstream behaviour this repository had to measure to hide: with zero modules
configured, iOS's initializeSdk completion never fires — it reports per-module statuses,
and there are none. InitializeAsync knows that (see Platforms/Apple), so the same await
completes on both platforms.
What the façade does and does not hide
The façade carries what an app does from shared code — initialize once, identify the user, track events, read a diagnostic line — and deliberately nothing else. It adds no abstraction over things only one platform has, and it does not wrap surfaces an app touches from platform code anyway.
Everything else stays reachable, because the packages underneath arrive with this one:
- Android: namespace
Com.Salesforce.Marketingcloud.Sfmcsdk(packageSFMCSDK.Net.Android) — module configs, the event bus, behaviors, encrypted storage, the in-app messaging models,RequestSdkitself. - iOS: namespace
SFMCSDK(packageSFMCSDK.Net.iOS) — the config builder's per-module setters, consent/CDP, keychain helpers, the full event model.
Mixing is fine and expected: initialize and track through the façade, and configure a module or subscribe to the event bus through the raw namespace in the same app. The façade builds an empty module config on both platforms — module packages (MobilePush and friends) are configured through the raw builders, which is where their options live.
On the plain net8.0/net9.0/net10.0 target frameworks the package restores and compiles —
that is what lets a shared class library, a unit test, or a MAUI app's Windows head reference it
unconditionally — and every member that would reach the native SDK throws
PlatformNotSupportedException naming where the real implementation lives. Throwing, not
no-oping: an identity edit that silently vanished on a Windows head would read as data loss in
Marketing Cloud.
Two members are exempt and never throw, on any target framework, because they are what shared code
branches on: IsSupported (is there a native SDK under this build?) and IsInitialized (did an
InitializeAsync on this client succeed?). For a head that has no SDK and wants the calls to be
harmless rather than fatal, the package also ships NullSfmcSdkClient — a no-op implementation that
still validates its arguments, so it hides platforms without hiding your bugs:
builder.Services.AddSingleton<ISfmcSdkClient>(
new SfmcSdkClient() is { IsSupported: true } client ? client : new NullSfmcSdkClient());
Using MobilePush too? Then you do not need this client at all:
MarketingCloudSDK.Netcomposes one internally and re-exportsIdentityandTrackCustomEvent, and its initialization brings this core up underneath with the MobilePush module attached. Never call this package'sInitializeAsyncin an app that initializes MobilePush — that configures the core a second time with an empty module set, which upstream forbids.
Packages and versions
One package. The version is <SFMCSDK iOS version>.<binding revision> — 4.0.1.2 is SFMCSDK
4.0.1, revision 2, and the Android side of the same release is sfmcsdk 3.1.1.
Why one version names one SDK. Salesforce releases the iOS and Android SDKs on separate cadences and their version numbers have never matched. A façade over both has to pick one line to name itself after or invent a third numbering that maps to nothing. It picks iOS — the same convention the sibling DatadogNet façade uses — and states the Android version everywhere it states its own.
| SFMCSDK.Net | SFMCSDK (iOS, native) | sfmcsdk (Android, native) | SFMCSDK.Net.iOS | SFMCSDK.Net.Android |
|---|---|---|---|---|
| 4.0.1.2 | 4.0.1 | 3.1.1 | 4.0.1.2 | 3.1.1.1 |
The platform packages are pinned exactly ([4.0.1.2] / [3.1.1.1]), not floored: the façade
calls each binding's hand-written convenience layer — the Action overloads of
Configure/RequestSdk on Android, the Func-typed Identity.Edit trampoline on iOS — and
those carry no compatibility promise across binding revisions. A newer binding is consumed by
this repository re-pinning and releasing, with the device suites in between, not by NuGet
floating a consumer onto it.
Installing
<PackageReference Include="SFMCSDK.Net" Version="4.0.1.2" />
Nine target frameworks: net8.0, net9.0, net10.0, each with its -android and -ios head —
net8.0-android34.0, net8.0-ios18.0, net9.0-android35.0, net9.0-ios18.0,
net10.0-android36.0, net10.0-ios26.0. Floors: iOS 12.2 (the SFMC framework is Swift and
relies on the OS Swift runtime, ABI-stable from 12.2), Android API 26 (the sfmcsdk .aar
manifest's own floor).
The platform heads pull SFMCSDK.Net.Android 3.1.1.1 / SFMCSDK.Net.iOS 4.0.1.2 transitively;
apps reference only this package unless they want the raw namespaces pinned explicitly (they may
— the same versions arrive either way).
For a MAUI app with an Android head, target net10: MAUI 8 and 9 cannot build against the AndroidX generation the SFMC Android binding's dependencies resolve to (a Java-callable-wrapper defect the binding repository's README records), while MAUI 10 handles it. An iOS-only MAUI app is fine on net8 or net9. The net8 and net9 assets are otherwise for plain .NET Android / .NET iOS apps, which is also what the binding packages' own net8 support is for.
Usage notes
- One client per process. The native SDK on both platforms is a process-wide singleton
behind static entry points, so
SfmcSdkClientis meant to live as a DI singleton. The initialization guard is per-instance — the instance is what promises one-shot semantics. InitializeAsyncis one-shot, even on failure. A second call throwsInvalidOperationException. The guard deliberately does not reset on failure: the native configure is itself not retryable, and after a timeout the first attempt may still be running — a retry would race it. A process that needs a fresh attempt restarts.- Identity is fire-and-forget, before or after initialization — both SDKs queue early identity work. Nothing waits for a server acknowledgement; changes batch on the SDK's own schedule.
DiagnosticStateis for logs, never for parsing. iOS returns the SDK's state JSON; Android's equivalent detail lives on the instancerequestSdkdelivers asynchronously, so the synchronous property reports the static initialization state (NONE/INITIALIZING/READY/ERROR) instead.- Push is not here. MobilePush lives in the MarketingCloudSDK binding repositories, which
depend on these same core bindings. For a cross-platform MobilePush surface use
MarketingCloudSDK.Net— it composes this façade rather than sitting beside it; for module-level control, the raw config builders. IsSupportedandIsInitializednever throw. They are the two members shared code branches on, so they answer on every target framework — including the neutral ones, where everything else throws.IsInitializedis a status signal, not a precondition: identity work is legal before initialization because the SDK queues it. It is also not the one-shot guard — after a failed initialization it stays false while the guard stays claimed.
How this repository works
Nothing is bound here and nothing native is committed. The repository compiles one multi-targeted
assembly against the two pinned binding packages: shared sources declare the API and
private partial …Core seams, and exactly one of Platforms/Android, Platforms/Apple or
Platforms/Neutral supplies the bodies per target framework (see src/Sfmc.Facade.props).
Each .NET SDK's android/ios workloads ship reference packs for only two target frameworks — the .NET 9 band covers net8/net9, the .NET 10 band covers net9/net10 — so build/BuildNugets.sh packs twice and build/merge-packages.py grafts the net10 assets and their dependency groups into one package. The groups matter as much as the assemblies: an empty net10 group would tell NuGet a net10 consumer needs no binding underneath, and the app would fail with the native SDK missing.
The pins live in Directory.Build.props as literal properties
(SfmcAndroidPackageVersion, SfmcIosPackageVersion) that the scripts and workflows sed-parse.
NuGet.config adds ./artifacts as a package source, so locally packed platform bindings (from
the sibling repositories) and the locally packed façade both resolve without publishing anything.
Layout
| Path | What |
|---|---|
src/Sfmc.Facade.props |
TFM bands, per-platform source selection, compiler settings, pack assets |
src/SFMCSDK.Net/ |
The façade: shared half + Platforms/{Android,Apple,Neutral} |
build/ |
Pack, merge, README-check and upstream-check scripts; packages.tsv is the roster |
tests/ |
UnitTests (neutral leg, no workloads), PackageTests (nupkg shape), DeviceTests (one project, two heads, driving the façade over the packed package) |
samples/ |
A MAUI app driving initialization, identity and events through the façade, zero #if |
.github/workflows/ |
pr, build, release, auto-release |
Building locally
cp ../SFMCSDK.Net.Android/artifacts/*.nupkg ../SFMCSDK.Net.iOS/artifacts/*.nupkg artifacts/ # or let nuget.org serve them
./build/BuildNugets.sh # packs 4.0.1.2 into ./artifacts
dotnet test tests/SFMCSDK.Net.UnitTests -p:SfmcNeutralOnly=true
dotnet test tests/SFMCSDK.Net.PackageTests
Tests
Three suites, cheapest first — each catches what the previous one cannot see:
dotnet test tests/SFMCSDK.Net.UnitTests -p:SfmcNeutralOnly=true # validation, guard, neutral contract
dotnet test tests/SFMCSDK.Net.PackageTests # nine TFMs, exact pins, licence, symbols
./.github/scripts/run-simulator-tests.sh 4.0.1.2 net9.0-ios18.0 # the façade over the real SDK
./.github/scripts/run-emulator-tests.sh 4.0.1.2 net9.0-android35.0
-p:SfmcNeutralOnly=true collapses the referenced façade to its neutral target frameworks, so
the unit tests restore and run on any machine with no mobile workloads — it is the pipeline's
fastest signal, and must never be set on a pack (it would produce a package with no platform
legs).
The device checks run without credentials on purpose: the empty module config points at no
tenant, so server calls fail by design. What they prove is the packaging promise — the packed
package's dependency groups pull the right binding in on each platform, and the façade drives it:
InitializeAsync completes, identity and tracking cross into native code, the one-shot guard
holds, and DiagnosticState answers.
Upgrading
Re-pin, verify, release — in that order:
- Bump
SfmcAndroidPackageVersion/SfmcIosPackageVersion(and, when the native line moved,SfmcNativeVersion+SfmcAndroidNativeVersion) inDirectory.Build.props. - Update the version map above —
./build/CheckReadmeVersions.shfails the build until the README agrees with the props, which is the point. - Reset
SfmcBindingRevisionto 1 on a native bump, or increment it for a façade-only change. - Open a PR: the pipeline packs, validates the package shape and runs both device suites against the new pins before anything ships.
./build/check-upstream.sh (shared verbatim with the binding repositories, driven by
build/upstream.tsv) reports when Salesforce publishes a native version
newer than the pinned lines — the early signal that the binding repositories will move and this
façade will need re-pinning behind them.
Releasing
Merging a file named docs/release-notes/<four-part-version>.md to main is the release:
auto-release tags it, release verifies the tag is on the default branch (the guard), packs
with verification off — the tagged commit was already verified on its pull request — and
publishes to nuget.org via trusted publishing (OIDC, no stored API key). Every pull request
publishes a -beta.<pr>.<run> prerelease.
Troubleshooting
PlatformNotSupportedException mentioning the neutral build. The code ran on a plain target
framework (a unit test, a Windows head). That is the documented contract — construct freely,
guard the calls, or inject a fake of ISfmcSdkClient; the real implementation runs in
net*-android / net*-ios heads.
InvalidOperationException from a second InitializeAsync. Initialization is one-shot per
client and per process — see Usage notes. Await the first call; do not retry.
Restore fails with NU1301 naming artifacts. The local package source must exist:
mkdir -p artifacts (a fresh clone has it via the committed .gitkeep).
A stale package in artifacts/ shadows nuget.org, and NU1107 blames a version you never
pinned. The local feed is searched alongside nuget.org, so a nupkg packed here before a
dependency was re-pinned keeps being resolved under the same version number — and once restored it
is cached in the NuGet global-packages folder, where it poisons every other repository too. Clearing
both is the fix:
rm -f artifacts/*.nupkg artifacts/*.snupkg && rm -rf ~/.nuget/packages/sfmcsdk.net/<version> && dotnet restore --force
The tell is a NU1107 naming two versions of the same binding, one of which matches no
Directory.Build.props pin in any repository.
NU1608 warnings about AndroidX Lifecycle versions. A property of the AndroidX graph the SFMC Android binding pulls in — two of its packages exact-range a sibling that a third floats past. NuGet resolves the higher version and the result is what the binding's emulator tests pass against. This repository suppresses the warning rather than pinning AndroidX for every consumer; pin in your app if you want it gone.
A surface you need is missing from the façade. Deliberate — see What the façade does and does not hide. Use the platform namespaces; they ship in the same restore.
Licence
The code in this repository is MIT, and the package declares plain MIT — it ships no
native binaries, so Salesforce's licence is not its to declare. The binding packages underneath
ship the native SFMC SDK artifacts (© Salesforce, BSD-3-Clause) and each declares
MIT AND BSD-3-Clause with both texts packed; the BSD text is mirrored at
licenses/BSD-3-Clause-Salesforce.txt for reference.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-android34.0 is compatible. net8.0-browser was computed. net8.0-ios was computed. net8.0-ios18.0 is compatible. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-android35.0 is compatible. net9.0-browser was computed. net9.0-ios was computed. net9.0-ios18.0 is compatible. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-android36.0 is compatible. net10.0-browser was computed. net10.0-ios was computed. net10.0-ios26.0 is compatible. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net10.0-android36.0
- SFMCSDK.Net.Android (= 3.1.1.1)
-
net10.0-ios26.0
- SFMCSDK.Net.iOS (= 4.0.1.2)
-
net8.0
- No dependencies.
-
net8.0-android34.0
- SFMCSDK.Net.Android (= 3.1.1.1)
-
net8.0-ios18.0
- SFMCSDK.Net.iOS (= 4.0.1.2)
-
net9.0
- No dependencies.
-
net9.0-android35.0
- SFMCSDK.Net.Android (= 3.1.1.1)
-
net9.0-ios18.0
- SFMCSDK.Net.iOS (= 4.0.1.2)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on SFMCSDK.Net:
| Package | Downloads |
|---|---|
|
MarketingCloudSDK.Net
One cross-platform API for Salesforce Marketing Cloud MobilePush: awaitable initialization with real credentials, identity (contact key and attributes) via the SFMC SDK core, registration reads and tag/attribute edits on Android and iOS from shared code, over the MarketingCloudSDK.Net.Android and MarketingCloudSDK.Net.iOS bindings. Restores on plain target frameworks too, so shared class libraries and unit tests need no platform conditionals. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.1.2 | 28 | 7/30/2026 |
| 4.0.1.2-beta.4.12 | 29 | 7/30/2026 |
| 4.0.1.1 | 118 | 7/30/2026 |
| 4.0.1.1-beta.3.10 | 28 | 7/30/2026 |
| 4.0.1.1-beta.2.7 | 29 | 7/30/2026 |
| 4.0.1.1-beta.2.5 | 35 | 7/30/2026 |
| 1.1.3.8 | 271 | 10/30/2024 |
| 1.1.3.6 | 203 | 10/7/2024 |
| 1.1.2.10 | 202 | 7/12/2024 |
| 1.1.2.4 | 198 | 7/12/2024 |
| 1.1.2.3 | 183 | 5/20/2024 |
| 1.1.2.2 | 191 | 5/17/2024 |
| 1.1.1.80 | 215 | 2/28/2024 |
| 1.1.1.79 | 237 | 2/22/2024 |
| 1.1.1.1 | 222 | 2/12/2024 |
| 1.0.3.73 | 253 | 2/7/2024 |
## What's changed
A façade-only revision on the same native line: SFMCSDK stays **4.0.1** on iOS and sfmcsdk **3.1.1**
on Android, and the pinned bindings are unchanged (`SFMCSDK.Net.iOS 4.0.1.2`,
`SFMCSDK.Net.Android 3.1.1.1`).
### Added
- **`IsSupported` and `IsInitialized`** on `ISfmcSdkClient` — the two members that answer on every
target framework instead of throwing, so shared code has something to branch on. Previously the
only readback was `DiagnosticState`, which the docs forbid parsing and which throws on the neutral
heads, so "is it up yet?" could not be answered from shared code at all. Neither is the one-shot
guard: after a failed initialization `IsInitialized` stays false while the guard stays claimed.
- **`NullSfmcSdkClient`** — the no-op implementation the README used to tell consumers to write
themselves, for MAUI heads with no SDK and for tests. Its argument validation mirrors the real
façade member for member, so it hides platforms without hiding caller bugs.
- **`build/RepinPackages.sh`** — the re-pin helper the upstream-drift issue template has been
pointing at under a name that did not exist (`BumpNativeVersion.sh`). It rewrites the props and
reports what the README still owes, leaving `CheckReadmeVersions.sh` as the gate.
### Documentation
- The MAUI target-framework guidance said "target net9 or net10" and then explained that MAUI 9
cannot work — it is net10 for an Android head, net8/net9 fine for an iOS-only app.
- Stated the relationship with `MarketingCloudSDK.Net` in both directions: an app using MobilePush
needs only that package, and must never call **this** package's `InitializeAsync`, because the
MobilePush façade already configures the core with its module attached.
- Added the stale-`artifacts/` troubleshooting entry: a locally packed nupkg keeps being resolved
under a version that also exists on nuget.org, and the NuGet global-packages cache spreads it to
every sibling repository.
### Upgrading
Source-compatible for consumers. Implementers of `ISfmcSdkClient` — a hand-written test fake — must
add `IsSupported` and `IsInitialized`, or switch to `NullSfmcSdkClient`.