How to Build a Custom Connector Using Simplify.Connectors.SDK (SimplifyV2 Integration Guide)

Modified on Wed, Sep 23 at 10:11 PM

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:

  1. Portal → Custom Connector page → Download SDK button.
  2. 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 blob assets/sdk/<fileName>. The version list is currently hardcoded on the frontend (Simplify.Web/ClientApp/src/core/constants.tsconstants.customConnectorSdk.versions, currently only 0.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 from dotnet.microsoft.com).
  • The .nupkg file above.

1.3. Creating the project

dotnet new classlib -n MyConnector -f net10.0 cd MyConnector
  • The classlib template is mandatory — the Runner loads the connector as a DLL via AssemblyLoadContext, it does not run a Main. If you accidentally created console/web, delete it and start over.
  • -f net10.0 must 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 like D:\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.org since 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 -Source is 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 => { /* ... */ }));    } }
MethodUsed 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

PropertyMeaning
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
BodyAlias for Input — reads more naturally in a webhook handler
Http (HttpClient)See §5
EnvironmentConnectorEnvironment.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: EgressEnforcementValidator statically scans PE metadata and rejects any artifact referencing Socket/TcpClient/UdpClient/WebRequest/HttpWebRequest/FtpWebRequest/WebClient/HttpClientHandler/SocketsHttpHandler or HttpClient..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 the Authenticate request itself) is automatically caught and thrown as ConnectorAuthenticationExceptionno need to check StatusCode == 401 yourself.
  • 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 OnAuthenticate again exactly once, with ctx.Auth set to the old (non-empty) auth blob → the connector can distinguish "first-time connect" (Auth empty) from "refresh" (Auth has data). If the refresh succeeds, the original request is retried exactly once, no recursion.
  • ConnectorEntrypointNotFoundException is thrown automatically by the SDK when key/kind doesn't match any registered trigger/action — the connector author doesn't need to handle this.

7. Credentials — 3 sources, don't confuse them

SourceSDK propertyWho stores it
① Field the customer entered when connecting/updatingctx.ConfigApplication 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 SDKThe connector author's own responsibility — the SDK has no secret store for the connector
③ Auth blob returned after OnAuthenticateAuthResult.Datactx.Auth next timeSimplify stores it as-is into IntegrationConnection.MetadataJson, without reading/interpreting the content (opaque)

Safety notes:

  • secret: true on a Field is only a UI hint (masks the input), not encryption at the SDK layer.
  • AuthJson travels 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}

PartWhat it is
aliasThe connector's alias declared at Register time (§9)
connectionIdThe specific IntegrationConnection's id — 1 connection = 1 unique URL per webhook trigger
triggerKeyThe exact key from AddWebhookTrigger("key", ...)
tokenThe 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 valid CustomConnectorVersionId, and that manifest version must have at least one Webhook-kind trigger. Otherwise the API returns [].
  • Display: the Portal calls GET /api/connections/{id}/webhook-urls, rendered in the connection form (one CopyInput row per webhook trigger). Only shown once the connection already exists (a connectionId is 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 uses AssemblyDependencyResolver to resolve the dependency closure; a missing .deps.json is blocked immediately at Register time by ArtifactPackagingValidator.HasDepsJson.
  • Do not put Simplify.Connectors.SDK.dll inside the zip — this avoids two different type identities inside the AssemblyLoadContext.
  • Limits at Register time:
    • Zip ≤ 50MB (AppConstants.Integration.MaxArtifactZipBytes).
    • Alias must be globally unique, format [a-z0-9-]{1,37} (37 = IdMaxLength 40 − 3 characters for the cc_ prefix).
    • Version must be valid SemVer (MAJOR.MINOR.PATCH[-pre][+build]).
  • Both Register (first time) and Upload Version (later builds) trigger Describe to fetch the manifest and check that the SDK major version matches ConnectorSandboxConstants.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's RejectedEgressHosts.

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.cs in 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

Let us know how can we improve this article!

Select at least one of the reasons
CAPTCHA verification is required.

Feedback sent

We appreciate your effort and will try to fix the article