Writing a Custom Connector with Simplify.Connectors.SDK
Audience: partners/devs writing a connector to integrate a third-party system into SimplifyV2.
1. Project setup
1.1. Getting the package
Simplify.Connectors.SDK (currently 0.1.0) is a standard NuGet package but has not been published to any public/internal feed — partners get the .nupkg file directly one of two ways:
- Portal → Custom Connector page → Download SDK button.
- Direct download link provided by the Simplify team.
The Download SDK button calls
GET custom-connectors/download-sdk?fileName=...(DownloadCustomConnectorSdk.cs), returning the file from blobassets/sdk/<fileName>. The version list is currently hardcoded on the frontend (Simplify.Web/ClientApp/src/core/constants.ts→constants.customConnectorSdk.versions, currently only0.1.0), and each version's corresponding blob must be manually uploaded by ops — clicking before it's uploaded fails.
1.2. Requirements
- .NET SDK 10.0+ (
dotnet --version; if missing, download fromdotnet.microsoft.com). - The
.nupkgfile above.
1.3. Creating the project
dotnet new classlib -n MyConnector -f net10.0 cd MyConnector
- The
classlibtemplate is mandatory — the Runner loads the connector as a DLL viaAssemblyLoadContext, it does not run aMain. If you accidentally createdconsole/web, delete it and start over. -f net10.0must match the SDK's target framework exactly. Do not use .NET Framework / .NET Standard.- Delete the default
Class1.cs.
1.4. Pointing NuGet at the local package
Create a local-packages/ directory right inside the project (at the same level as the .csproj) and copy the .nupkg file into it, keeping the exact filename (NuGet needs the exact name to index it):
MyConnector/ ├── MyConnector.csproj ├── nuget.config └── local-packages/ └── Simplify.Connectors.SDK.0.1.0.nupkg
<!-- nuget.config -->
<?xml version="1.0" encoding="utf-8"?>
<configuration> <packageSources> <clear /> <add key="simplify-local" value="./local-packages" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" /> </packageSources>
</configuration>
- Use a relative path
./local-packages, not an absolute path likeD:\LocalNuget— an absolute path only works on one machine/one drive; copying the project to another machine (or to Mac/Linux) will break restore immediately. - Keep
nuget.orgsince the SDK may pull in public transitive dependencies.
1.5. Declaring PackageReference + building
<!-- MyConnector.csproj -->
<ItemGroup> <PackageReference Include="Simplify.Connectors.SDK" Version="0.1.0" />
</ItemGroup>
Equivalent (run from the directory containing the .csproj):
dotnet add package Simplify.Connectors.SDK --version 0.1.0 --source ./local-packages dotnet restore && dotnet build
You can also use the Package Manager Console in Visual Studio (
Install-Package Simplify.Connectors.SDK -Version 0.1.0 -Source ".\MyConnector\local-packages") — remember the PM Console runs from the solution directory, so-Sourceis resolved from there, and check that the Default project dropdown points to the right project.
Restore fails with package not found? Check in order: (a) you're in the directory containing nuget.config, (b) the .nupkg filename is exactly Simplify.Connectors.SDK.0.1.0.nupkg, (c) dotnet nuget locals all --clear then restore again.
Getting a new SDK version: drop the new .nupkg into the same local-packages/ (no need to edit nuget.config) → bump the version number in PackageReference → restore again.
2. Required interface: IConnector
Just one interface, no base class to inherit from:
public interface IConnector
{ void Configure(IConnectorBuilder builder); }
Hard constraint: Configure must be idempotent, no I/O, no side effects. The SDK calls it both during Describe (once at Register/Upload) and on every single Invoke (Authenticate/Trigger/Action).
There are no attributes/annotations ([Action], [Input], ...) — everything is declared through the fluent builder inside Configure.
3. Fluent builder API
The builder implementation is internal sealed — you never new it directly, you only receive it via a lambda.
public sealed class MyConnector : IConnector
{ public void Configure(IConnectorBuilder b) { b.Metadata("MyConnector", "1.0.0", egressHosts: ["api.example.com"]); b.Authentication(AuthType.ApiKey, a => { a.Field("apiKey", "API Key", secret: true); a.OnAuthenticate(async ctx => AuthResult.Success(data)); // or AuthResult.Failure(msg) }); b.AddScheduleTrigger("orders", t => { t.Field("since", "Modified since"); t.Handle(async ctx => new ConnectorResult { Content = json, ExternalId = id }); }); b.AddWebhookTrigger("order-updated", t => t.Handle(async ctx => { /* ... */ })); b.AddAction("create-order", a => a.Handle(async ctx => { /* ... */ })); } }
| Method | Used for |
|---|---|
Metadata(name, version, egressHosts?, supportsSandbox?) | Name, version, list of hosts the connector is allowed to call out to |
Authentication(AuthType, Action<IAuthBuilder>) | Declares the authentication mechanism |
IAuthBuilder.Field(key, label, secret, required) | Field the customer fills in when connecting (the Portal renders the form from this). required defaults to true |
IAuthBuilder.OnAuthenticate(handler) | Auth handler (verify credentials, exchange code for token, ...) |
IAuthBuilder.AuthorizeUrl(resolver) | Only for AuthType.OAuth2 — generates the redirect URL the user is sent to for authorization |
AddScheduleTrigger(key, ...) | Polling trigger (Simplify calls it periodically) |
AddWebhookTrigger(key, ...) | Push trigger (an external system calls into a webhook) |
AddAction(key, ...) | 1 action = 1 handler, invoked on demand by a flow |
Auth/trigger fields all share the ConnectorField { Key, Label, Secret, Required } shape — every field is plain text, there's no data type (int/enum/bool). Secret=true only decides whether the Portal masks the input, it's not encryption.
Action/Trigger input/output are both raw JsonElement/string — there is no strongly-typed schema.
4. IConnectorContext — the object passed into every handler
| Property | Meaning |
|---|---|
Config (JsonElement) | Fields the customer entered when connecting/updating (per the declared Field()s). For an OAuth2 callback: this is the query string the Portal forwards along |
Auth (JsonElement) | Opaque auth blob Simplify saved from the most recent successful OnAuthenticate (= AuthResult.Data). An empty object {} on the very first authenticate call |
Input (JsonElement) | Data from the previous step in the flow, or the body of the webhook request |
Body | Alias for Input — reads more naturally in a webhook handler |
Http (HttpClient) | See §5 |
Environment | ConnectorEnvironment.Production / Sandbox |
Log(message) | Writes a log line; Simplify caps it at 200 lines × 2000 characters |
5. ctx.Http — a convention, not a real sandbox
HttpClient → EgressAllowlistHandler (blocks hosts outside the admin-approved ApprovedEgressHosts) → AuthFailureDetectionHandler (401 → throws ConnectorAuthenticationException, except for the Authenticate call itself) → HttpClientHandler { AllowAutoRedirect = false }
ctx.Http is an HttpClient Simplify pre-wires with an allowlist, but this is only a convention at the application layer. A connector that new HttpClient()s itself or opens its own socket will still run fine at the SDK/Runner layer — but it will be genuinely blocked at 2 other points:
- At Register/Upload time:
EgressEnforcementValidatorstatically scans PE metadata and rejects any artifact referencingSocket/TcpClient/UdpClient/WebRequest/HttpWebRequest/FtpWebRequest/WebClient/HttpClientHandler/SocketsHttpHandlerorHttpClient..ctor. - At actual runtime: the K8s NetworkPolicy at the network layer.
→ Always use ctx.Http. Building your own HttpClient/socket = rejected immediately at upload time.
Egress host templates like Shopify's ({shopSubdomain}.myshopify.com): declare the template in Metadata(egressHosts: [...]), it's validated at Register time and resolved for real at dispatch time from the customer's entered field (preferring Config, falling back to Auth).
6. Handling authentication errors
- A bare HTTP 401 via
ctx.Http(except on theAuthenticaterequest itself) is automatically caught and thrown asConnectorAuthenticationException— no need to checkStatusCode == 401yourself. - If an auth error doesn't surface as a plain 401 (403, or a 200 OK with an error body) → the connector should itself
throw new ConnectorAuthenticationException(...). - When this exception is caught, Simplify automatically calls
OnAuthenticateagain exactly once, withctx.Authset to the old (non-empty) auth blob → the connector can distinguish "first-time connect" (Authempty) from "refresh" (Authhas data). If the refresh succeeds, the original request is retried exactly once, no recursion. ConnectorEntrypointNotFoundExceptionis thrown automatically by the SDK whenkey/kinddoesn't match any registered trigger/action — the connector author doesn't need to handle this.
7. Credentials — 3 sources, don't confuse them
| Source | SDK property | Who stores it |
|---|---|---|
| ① Field the customer entered when connecting/updating | ctx.Config | Application layer. The Portal renders the form 100% from Auth.Fields[] in the manifest |
| ② Constants the connector itself hard-codes (app client_id/secret, base URL, ...) | Doesn't go through the SDK | The connector author's own responsibility — the SDK has no secret store for the connector |
③ Auth blob returned after OnAuthenticate | AuthResult.Data → ctx.Auth next time | Simplify stores it as-is into IntegrationConnection.MetadataJson, without reading/interpreting the content (opaque) |
Safety notes:
secret: trueon aFieldis only a UI hint (masks the input), not encryption at the SDK layer.AuthJsontravels as plaintext on the Service Bus queue; if a message ends up in the dead-letter queue (retained 7 days), the credential is exposed in plaintext to anyone who can read the DLQ.- Never log secrets via
ctx.Log()— connector logs are not auto-redacted and flow into the centralized logging system.
8. Webhook URL — where does it come from
AddWebhookTrigger only declares the handler that processes the incoming payload; the SDK does not register the webhook with the third-party system itself. The actual URL the merchant pastes into the provider's webhook settings is built by the backend (CustomConnectorWebhookUrlBuilder.Build) — the connector author doesn't need to write any extra code for it.
Format: {scheme}://{host}/api/webhooks/custom/{alias}/{connectionId}/{triggerKey}?token={webhookSecret}
| Part | What it is |
|---|---|
alias | The connector's alias declared at Register time (§9) |
connectionId | The specific IntegrationConnection's id — 1 connection = 1 unique URL per webhook trigger |
triggerKey | The exact key from AddWebhookTrigger("key", ...) |
token | The WebhookSecret randomly generated when the connection is created, verified in CustomConnectorNotification. A wrong/missing token is rejected before it ever reaches your Handle(...) |
- Prerequisite for a URL to exist: the connection must have a
WebhookSecret+ a validCustomConnectorVersionId, and that manifest version must have at least oneWebhook-kind trigger. Otherwise the API returns[]. - Display: the Portal calls
GET /api/connections/{id}/webhook-urls, rendered in the connection form (oneCopyInputrow per webhook trigger). Only shown once the connection already exists (aconnectionIdis needed to build the URL). - Regenerate: the "Regenerate" button →
PUT /api/connections/{id}/regenerate-webhook-secret. The old URL is invalidated immediately; the merchant must update it on the provider's side.
9. Packaging & upload
- Build/publish the project then zip it together with
.deps.json— this is mandatory. The Runner usesAssemblyDependencyResolverto resolve the dependency closure; a missing.deps.jsonis blocked immediately at Register time byArtifactPackagingValidator.HasDepsJson. - Do not put
Simplify.Connectors.SDK.dllinside the zip — this avoids two different type identities inside theAssemblyLoadContext. - Limits at Register time:
- Zip ≤ 50MB (
AppConstants.Integration.MaxArtifactZipBytes). - Alias must be globally unique, format
[a-z0-9-]{1,37}(37 =IdMaxLength40 − 3 characters for thecc_prefix). - Version must be valid SemVer (
MAJOR.MINOR.PATCH[-pre][+build]).
- Zip ≤ 50MB (
- Both Register (first time) and Upload Version (later builds) trigger
Describeto fetch the manifest and check that the SDK major version matchesConnectorSandboxConstants.SdkMajorVersion(currently = 0). A mismatch is rejected immediately. - Egress hosts declared in
Metadata(egressHosts:...)are auto-approved against the system's allowed list; hosts that get rejected are returned in the upload response'sRejectedEgressHosts.
10. Sample code
The sample below is written generically (not tied to a specific provider) so it's easy to use as a template. Real source, tied to each provider's actual API: SDK/Simplify.Connectors/samples/ (ShipStation, ShopifyV2, ShopifyV3).
Start with §10.1 — this sample covers the common cases (AuthType.ApiKey + schedule trigger + webhook trigger + action). Only look at §10.2 if you need OAuth2.
10.1. ApiKey + all 3 entrypoint kinds
⚠️ The real source
ShipStationConnector.csin the repo is currently out of sync with its own test (connector/action names differ between the code and the test) — unrelated to the generic sample below, just a heads-up if you cross-reference that file directly.
public sealed class SampleConnector : IConnector
{ public void Configure(IConnectorBuilder b) { b.Metadata("SampleConnector", "1.0.0", egressHosts: ["api.example.com"]); b.Authentication(AuthType.ApiKey, a => { a.Field("apiKey", "API Key", secret: true); a.OnAuthenticate(async ctx => { var apiKey = ctx.Config.GetProperty("apiKey").GetString(); using var res = await GetWithApiKey(ctx, "https://api.example.com/v1/ping", apiKey!); return res.IsSuccessStatusCode ? AuthResult.Success(JsonSerializer.SerializeToElement(new { apiKey })) : AuthResult.Failure($"status {(int)res.StatusCode}"); }); }); b.AddScheduleTrigger("orders", t => { t.Field("since", "Modified since"); t.Handle(async ctx => { var since = ctx.Config.GetProperty("since").GetString(); var json = await GetJson(ctx, $"https://api.example.com/v1/orders?updated_since={since}"); return new ConnectorResult { Content = json, ExternalId = TryGetId(json) }; }); }); // "Thin payload" webhook case: body only has resourceUrl, the handler calls back for full content. b.AddWebhookTrigger("order-updated", t => t.Handle(async ctx => { var json = await GetJson(ctx, ctx.Body.GetProperty("resourceUrl").GetString()!); return new ConnectorResult { Content = json, ExternalId = TryGetId(json) }; })); b.AddAction("create-order", a => a.Handle(async ctx => { var json = await PostJson(ctx, "https://api.example.com/v1/orders", ctx.Input); return new ConnectorResult { Content = json, ExternalId = TryGetId(json) }; })); } // GetWithApiKey / GetJson / PostJson / TryGetId: HTTP helpers that attach the "API-Key" header // from ctx.Auth and parse the response — see the real source for the full implementation.
}
10.2. OAuth2 (redirect + callback) + signature verification
Only Authentication(...) differs from §10.1; AddScheduleTrigger/AddWebhookTrigger/AddAction are written exactly the same, just swap in reading ctx.Auth.GetProperty("accountSubdomain") / ("accessToken").
public sealed class SampleOAuthConnector : IConnector
{ // ClientId/ClientSecret: issued by the provider when the app is created (Partner Dashboard, ...), // hard-coded (not a Field). The values below are illustrative only — a real connector uses the // values the provider actually issues. internal const string ClientId = "sample-client-id"; internal const string ClientSecret = "sample-client-secret"; // Fixed by the "{origin}/auth-callback?integrationId=cc_{alias}" convention — NOT a Field(), // because the merchant never enters this value themselves. // "cc_" + alias is the standard Integration.Id rule for every custom connector // (AppConstants.Integration.CustomConnectorIdPrefix). internal const string RedirectUri = "https://.../auth-callback?integrationId=cc_sampleoauth"; public void Configure(IConnectorBuilder b) { b.Metadata("SampleOAuthConnector", "1.0.0", egressHosts: ["{accountSubdomain}.example.com"]); b.Authentication(AuthType.OAuth2, a => { a.Field("accountSubdomain", "Account subdomain, e.g. \"my-store\""); a.AuthorizeUrl(ctx => $"https://{ctx.Config.GetProperty("accountSubdomain").GetString()}.example.com/oauth/authorize" + $"?client_id={ClientId}&scope=...&redirect_uri={Uri.EscapeDataString(RedirectUri)}"); // The sample has no "state" param — a production connector should add one to prevent CSRF.
a.OnAuthenticate(async ctx => { // ctx.Config here is the OAuth callback's query string, forwarded as-is by the Portal. if (!ctx.Config.TryGetProperty("code", out var codeProp)) return AuthResult.Failure("Missing code — the provider does not support refreshing this token type, reconnect is required."); if (!VerifySignature(ctx.Config, ClientSecret)) return AuthResult.Failure("Signature verification failed."); var subdomain = ctx.Config.GetProperty("accountSubdomain").GetString()!; var token = await ExchangeCodeForToken(ctx, subdomain, codeProp.GetString()!); return AuthResult.Success(JsonSerializer.SerializeToElement(new { accountSubdomain = subdomain, accessToken = token })); }); }); } // VerifySignature: verifies the callback signature per the provider's scheme (e.g. HMAC over the // query params with the client secret, constant-time comparison). ExchangeCodeForToken: exchanges // the code for a token via the standard OAuth2 flow (POST to the provider's token endpoint). // Full implementation — see the real source.
}Was this article helpful?
That’s Great!
Thank you for your feedback
Sorry! We couldn't be helpful
Thank you for your feedback
Feedback sent
We appreciate your effort and will try to fix the article