Example file
dotnet/Program.cs
C# (.NET 8) with three modes, get-token, send-lead and token-server. Copy the SubcontextTokens class into your own code.
// Subcontext access tokens in .NET 8. Gets a token, sends a lead, or runs a
// small token endpoint for your web pages. Pick one with the first argument:
//
// dotnet run -- get-token gets a token and shows it is reused from memory
// dotnet run -- send-lead sends one lead to a campaign, then checks what became of it
// dotnet run -- token-server answers GET (or POST) http://localhost:8081/subcontext-token
//
// An access token lets your code use the Subcontext API for two hours.
// You get one by sending your API application's client id
// and client secret to Subcontext (an OAuth "client credentials" request).
// Run this on your server, never in a web page, because it holds the secret.
//
// Settings come from environment variables:
//
// SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET: from the Subcontext
// dashboard, Integrations, API applications. The client secret is shown only
// once, when you create the application (or rotate its secret).
// For send-lead, use an application with no allowed origins (server-only)
// and the read, write and crm scopes. For token-server, use one with your
// website under "Allowed origins" and only read and write.
// SUBCONTEXT_SCOPE (optional, get-token): scopes to ask for, default "read".
// SUBCONTEXT_CAMPAIGN_KEY (send-lead): the campaign the lead belongs to. In the
// dashboard, CRM, Campaigns, open the campaign. Its key is under its name.
// ALLOWED_ORIGIN (optional, token-server): the website whose pages may ask for
// a token. Default http://localhost:4100. It must also be in the API
// application's allowed origins.
// PORT (optional, token-server): default 8081.
// SUBCONTEXT_BASE_URL (optional): the Subcontext address. Default
// https://subcontext.com.
//
// Needs the .NET 8 SDK. No NuGet packages.
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var mode = args.FirstOrDefault() ?? "";
var baseUrl = (Environment.GetEnvironmentVariable("SUBCONTEXT_BASE_URL") ?? "https://subcontext.com").TrimEnd('/');
var clientIdSetting = Environment.GetEnvironmentVariable("SUBCONTEXT_CLIENT_ID");
var clientSecretSetting = Environment.GetEnvironmentVariable("SUBCONTEXT_CLIENT_SECRET");
if (mode is not ("get-token" or "send-lead" or "token-server"))
{
Console.Error.WriteLine("Choose one: dotnet run -- get-token | send-lead | token-server");
return 1;
}
if (string.IsNullOrEmpty(clientIdSetting) || string.IsNullOrEmpty(clientSecretSetting))
{
Console.Error.WriteLine("Set SUBCONTEXT_CLIENT_ID and SUBCONTEXT_CLIENT_SECRET (see the comment at the top of Program.cs).");
return 1;
}
string clientId = clientIdSetting, clientSecret = clientSecretSetting;
// One HttpClient for the whole program, as .NET recommends.
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
try
{
return mode switch
{
"get-token" => await GetTokenExample(),
"send-lead" => await SendLeadExample(),
_ => await TokenServer(args),
};
}
catch (TokenRequestException error)
{
Console.Error.WriteLine(error.Message);
return 1;
}
// get-token gets a token twice and shows the second one came from memory.
async Task<int> GetTokenExample()
{
var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret,
Environment.GetEnvironmentVariable("SUBCONTEXT_SCOPE") ?? "read");
var first = await tokens.GetAsync();
Console.WriteLine($"Got a token: {first.AccessToken[..8]}...");
Console.WriteLine($"It runs out at {first.ExpiresAt:u}");
var second = await tokens.GetAsync();
Console.WriteLine(second.AccessToken == first.AccessToken
? "Asked again: the same token came from memory, no new request."
: "Asked again: got a new token.");
return 0;
}
// send-lead gets a token, sends one lead with an Idempotency-Key, then checks what became of it.
// This sends a real lead. Put your own name and email address in the lead below.
async Task<int> SendLeadExample()
{
var campaignKey = Environment.GetEnvironmentVariable("SUBCONTEXT_CAMPAIGN_KEY");
if (string.IsNullOrEmpty(campaignKey))
{
Console.Error.WriteLine("Set SUBCONTEXT_CAMPAIGN_KEY (see the comment at the top of Program.cs).");
return 1;
}
// write and crm are both needed to send leads.
var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret, "read write crm");
var token = (await tokens.GetAsync()).AccessToken;
// The lead. Replace these with your own details while you try it.
var lead = new JsonObject
{
// Your system's name. Lowercase letters, numbers, "_", "." and "-", up to 64.
["source"] = "my_website",
["campaign"] = campaignKey,
// Who they are. An email, a phone number or your own reference is needed
// to recognise the same person next time.
["identity"] = new JsonObject
{
["first_name"] = "YOUR_FIRST_NAME",
["last_name"] = "YOUR_LAST_NAME",
["email"] = "you@example.com",
["external_id"] = "your-ref-0001", // your own reference, if you have one
},
// Only what the person agreed to. Leave a channel out if they didn't.
["consent"] = new JsonObject
{
["service"] = new JsonObject { ["email"] = true },
["method"] = "web_form",
["given_at"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
// Where they came from. Subcontext reads the utm tags from page_url.
["provenance"] = new JsonObject
{
["page_url"] = "https://www.example.com/contact?utm_source=newsletter",
["referrer"] = "https://www.example.com/",
},
// Anything else, as free text. Read by Subcontext's AI to find details.
["text"] = "Asked about cover for a new mortgage.",
};
// One key per lead. Keep it with your own record of the lead and send the
// same key again if you retry, so Subcontext knows it's the same lead.
var idempotencyKey = Guid.NewGuid().ToString();
Console.WriteLine($"Got a token. Sending the lead with Idempotency-Key {idempotencyKey}");
// Tries once more, with the same key, if the connection fails or
// Subcontext has a problem on its side (a 5xx status).
HttpResponseMessage? response = null;
for (var attempt = 1; attempt <= 2; attempt++)
{
var request = new HttpRequestMessage(HttpMethod.Post, baseUrl + "/api/v1/crm/ingests")
{
Content = new StringContent(lead.ToJsonString(), Encoding.UTF8, "application/json"),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("Idempotency-Key", idempotencyKey);
try
{
response = await http.SendAsync(request);
if ((int)response.StatusCode < 500) break;
}
catch (HttpRequestException) when (attempt < 2) { }
}
var body = await Json.Read(response!);
if (response!.StatusCode != HttpStatusCode.Accepted)
{
// Errors look like { "error": { "code": "...", "message": "...", "fields": { ... } } }.
// A 401 or a 403 can have no body. See https://subcontext.com/developers/oauth/reference#errors-and-fixes.
var error = body?["error"];
Console.Error.WriteLine($"The lead was not accepted ({(int)response.StatusCode}): {error?["code"]} {error?["message"]}");
return 1;
}
// 202 means Subcontext has stored the lead. It is processed in the
// background, and the Location header is where to ask what became of it.
var ingest = body!["data"]!;
var duplicate = ingest["duplicate"]!.GetValue<bool>();
Console.WriteLine("Accepted." + (duplicate ? " (Subcontext already had this one: no second lead was made.)" : ""));
Console.WriteLine($" id: {ingest["id"]}");
Console.WriteLine($" status: {ingest["status"]} - {ingest["explanation"]}");
var location = response.Headers.Location;
for (var check = 0; check < 5 && location != null; check++)
{
await Task.Delay(2000);
var poll = new HttpRequestMessage(HttpMethod.Get, new Uri(new Uri(baseUrl), location));
poll.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var pollResponse = await http.SendAsync(poll);
if (!pollResponse.IsSuccessStatusCode) break;
var now = (await Json.Read(pollResponse))!["data"]!;
Console.WriteLine($" status now: {now["status"]} - {now["explanation"]}");
if (now["status"]!.GetValue<string>() is not ("received" or "resolving" or "extracting")) break;
}
return 0;
}
// token-server is a small endpoint that gives your pages { access_token, expires_in }.
// On a real website, add the same MapGet to your own app at /subcontext-token,
// on the same website as the page, and the CORS lines aren't needed.
async Task<int> TokenServer(string[] commandLine)
{
var allowedOrigin = Environment.GetEnvironmentVariable("ALLOWED_ORIGIN") ?? "http://localhost:4100";
var port = Environment.GetEnvironmentVariable("PORT") ?? "8081";
// What the quote form needs. Never give a browser token the crm scope.
// One token is shared by every visitor until it nearly runs out. Asking
// Subcontext for a new one per visitor would soon hit the rate limit.
var tokens = new SubcontextTokens(http, baseUrl, clientId, clientSecret, "read write");
var builder = WebApplication.CreateBuilder(commandLine.Skip(1).ToArray());
builder.WebHost.UseUrls($"http://localhost:{port}");
builder.Services.AddCors(cors => cors.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigin).WithMethods("GET", "POST"))); // only your website's pages may read the answer
var app = builder.Build();
app.UseCors();
async Task<IResult> Handle(HttpRequest request)
{
// A page from another website gets nothing. This only stops browsers.
// Anyone can still call this endpoint from a script, which is why a
// browser token must only be able to do what a visitor may do anyway.
var origin = request.Headers.Origin.ToString();
if (origin.Length > 0 && origin != allowedOrigin)
return Results.Json(new { error = "origin not allowed" }, statusCode: 403);
try
{
var token = await tokens.GetAsync();
request.HttpContext.Response.Headers.CacheControl = "no-store";
return Results.Json(new
{
access_token = token.AccessToken,
// Seconds left on this token, which may be less than two hours.
expires_in = (int)(token.ExpiresAt - DateTimeOffset.UtcNow).TotalSeconds,
});
}
catch (Exception error) when (error is TokenRequestException or HttpRequestException or TaskCanceledException)
{
app.Logger.LogError("{Message}", error.Message);
return Results.Json(new { error = "could not get a token" }, statusCode: 502);
}
}
app.MapMethods("/subcontext-token", ["GET", "POST"], Handle);
Console.WriteLine($"Token server on http://localhost:{port}/subcontext-token for pages on {allowedOrigin}");
await app.RunAsync();
return 0;
}
static class Json
{
// The response body as JSON, or null if it is empty or not JSON.
public static async Task<JsonNode?> Read(HttpResponseMessage response)
{
var text = await response.Content.ReadAsStringAsync();
try { return text.Length > 0 ? JsonNode.Parse(text) : null; }
catch (JsonException) { return null; }
}
}
// Gets tokens and keeps the current one in memory until shortly before it
// runs out. Safe to share across threads. Copy this class into your own code.
sealed class SubcontextTokens(HttpClient http, string baseUrl, string clientId, string clientSecret, string scope)
{
// Ask for a new token this long before the current one runs out, so a
// token never expires in the middle of a request.
static readonly TimeSpan RenewEarly = TimeSpan.FromMinutes(5);
readonly SemaphoreSlim gate = new(1, 1);
CachedToken? current;
public async Task<CachedToken> GetAsync()
{
var token = current;
if (token != null && DateTimeOffset.UtcNow < token.ExpiresAt - RenewEarly) return token;
await gate.WaitAsync();
try
{
if (current != null && DateTimeOffset.UtcNow < current.ExpiresAt - RenewEarly) return current;
current = await RequestAsync();
return current;
}
finally
{
gate.Release();
}
}
// Forget the cached token, for example after the API answers 401.
public void Forget() => current = null;
async Task<CachedToken> RequestAsync()
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = scope,
// What the token is for (the Subcontext API). Required.
["resource"] = baseUrl + "/api/v1",
});
using var response = await http.PostAsync(baseUrl + "/oauth/token", form);
var body = await Json.Read(response);
if (!response.IsSuccessStatusCode)
{
var details = body?["error_description"] ?? body?["error"];
throw new TokenRequestException($"Token request failed ({(int)response.StatusCode}): {details?.ToString() ?? "no details"}");
}
return new CachedToken(
body!["access_token"]!.GetValue<string>(),
DateTimeOffset.UtcNow.AddSeconds(body["expires_in"]!.GetValue<int>()));
}
}
sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresAt);
sealed class TokenRequestException(string message) : Exception(message);