Using the ISA SDK with ASP.NET Core

Register the ISA SDK as a singleton in ASP.NET Core 8, run quotes from controllers, and verify webhooks.

Using the ISA SDK with ASP.NET Core

An ASP.NET Core 8 integration of the ISA .NET SDK. Register the client as a singleton in dependency injection (DI). Controllers inject it directly, a background service handles webhook events, and an exception filter translates SDK errors to HTTP responses.

Install

dotnet add package IsaSdk

The SDK targets net8.0 and ships nullable reference annotations.

Add your bearer token to appsettings.json. For production, override via environment variables (e.g., ISA_TOKEN and Isa__WebhookSecret). Never commit production secrets:

{
  "Isa": {
    "Token": "isa_test_4fjK2nQ7mX1aB8sR9pZ3",
    "WebhookSecret": "whsec_…"
  }
}

Set these in your hosting platform (Azure App Service, Kubernetes Secrets, AWS Parameter Store, etc.). The token alone determines test versus live mode.

Initialize the client

Register the IsaClient as a singleton in your DI container. One instance per process, shared across all requests. Since construction is async, either use AddSingleton(Func<IServiceProvider, T>) for lazy resolution, or build the client at startup.

// Program.cs
using Isa.Sdk;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Construct once; reads ISA_TOKEN from the environment.
var isa = IsaClient.WithBearer();

builder.Services.AddSingleton(isa);
builder.Services.AddControllers();

var app = builder.Build();

The SDK has no shared mutable state, so the singleton is safe for concurrent requests.

Load reference data

Cache datasets in IMemoryCache for 24 hours. Since datasets change rarely, a daily refresh keeps UIs responsive without excess bandwidth.

// Services/IsaDatasetsService.cs
using Microsoft.Extensions.Caching.Memory;
using Isa.Sdk;
using Isa.Sdk.Zyins;

public class IsaDatasetsService
{
    private readonly Isa _isa;
    private readonly IMemoryCache _cache;
    private static readonly TimeSpan TTL = TimeSpan.FromHours(24);

    public IsaDatasetsService(Isa isa, IMemoryCache cache)
    {
        _isa = isa;
        _cache = cache;
    }

    public Task<DatasetBundle> GetAsync(CancellationToken ct = default)
    {
        return _cache.GetOrCreateAsync("isa:datasets", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TTL;
            return _isa.Zyins.Datasets.GetAsync(ct);
        })!;
    }
}

Register the service: builder.Services.AddMemoryCache(); builder.Services.AddSingleton<IsaDatasetsService>();.

Run a quote

The canonical happy path: John Doe, 1962-04-18, NC, 5'10", 195 lb, no conditions or medications, Aetna Accendo final expense at $25,000 face value.

// Controllers/QuoteController.cs
using Microsoft.AspNetCore.Mvc;
using Isa.Sdk;
using Isa.Sdk.Zyins;
using Catalog = Isa.Sdk.Catalog;

[ApiController]
[Route("api/quote")]
public class QuoteController : ControllerBase
{
    private readonly Isa _isa;

    public QuoteController(Isa isa) => _isa = isa;

    [HttpPost]
    public async Task<IActionResult> Run(CancellationToken ct)
    {
        var applicant = new Applicant
        {
            Dob          = "1962-04-18",
            Sex          = Sex.Male,
            HeightInches = 70,
            WeightPounds = 195,
            State        = Catalog.States.WireValue(Catalog.State.NorthCarolina),
            NicotineUse  = NicotineUsage.None,
        };

        var input = new PrequalifyInput
        {
            Applicant = applicant,
            Coverage  = Coverage.ByFaceValue(25_000),
            Products  = new[]
            {
                new Product("aetna",
                    Catalog.Products.WireValue(Catalog.Product.FexAetnaAccendo)),
            },
        };

        var result = await _isa.Zyins.Prequalify.RunAsync(input, ct);

        Response.Headers["X-Isa-Request-Id"] = result.RequestId;
        return Ok(new
        {
            plans = result.Data.Plans.Select(p => new
            {
                Carrier        = p.Carrier.Name,
                Product        = p.Product.DisplayName,
                Category       = p.Eligibility.Category,    // "immediate" | "graded" | "rop"
                PremiumDisplay = p.Premium.Amount.Display,  // verbatim carrier string
                PremiumCents   = p.Premium.Amount.Cents,    // integer cents
            }),
            requestId = result.RequestId,
        });
    }
}

Money values come back as {cents, display}. Use cents (integer) for arithmetic. Use display (verbatim carrier string) for UI rendering.

CancellationToken flows through to the underlying HTTP call, so client disconnects cleanly abort the operation without orphaned underwriting runs.

Webhooks

The signature arrives in one header, X-ZyINS-Signature: t=<unix-seconds>,v1=<hex>. Verification requires the raw request body. ASP.NET Core consumes the body stream once by default, so enable buffering on your webhook route. No SDK ships a ZyINS webhook verifier yet, so parse the header, recompute the HMAC over <t>.<raw body>, and compare with CryptographicOperations.FixedTimeEquals.

// Program.cs (route group)
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

const int toleranceSeconds = 300;

app.MapPost("/webhooks/rapidsign", async (
    HttpContext ctx,
    IConfiguration config) =>
{
    ctx.Request.EnableBuffering();
    using var reader = new StreamReader(ctx.Request.Body, leaveOpen: true);
    var body = await reader.ReadToEndAsync();
    ctx.Request.Body.Position = 0;

    var header = ctx.Request.Headers["X-ZyINS-Signature"].ToString();
    var fields = header.Split(',')
        .Select(part => part.Split('=', 2))
        .Where(kv => kv.Length == 2)
        .ToDictionary(kv => kv[0], kv => kv[1]);

    if (!fields.TryGetValue("t", out var tsField)
        || !fields.TryGetValue("v1", out var receivedHex)
        || !long.TryParse(tsField, NumberStyles.None, CultureInfo.InvariantCulture, out var ts))
    {
        return Results.StatusCode(400);
    }
    var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
    if (Math.Abs(now - ts) > toleranceSeconds)
    {
        return Results.StatusCode(400);
    }

    var secret = config["Isa:WebhookSecret"]!;
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{ts}.{body}"));
    var received = Convert.FromHexString(receivedHex);
    if (!CryptographicOperations.FixedTimeEquals(expected, received))
    {
        return Results.StatusCode(400);
    }

    // De-dupe on the body's id (at-least-once delivery). Enqueue or persist here.
    return Results.NoContent();
});

Background fan-out

For high-volume traffic, accept the request and persist the event by ID, then process it asynchronously via IHostedService. The HTTP response returns immediately; processing happens off the request thread.

// Services/IsaEventDispatcher.cs
public class IsaEventDispatcher : BackgroundService
{
    private readonly Channel<IsaEvent> _events;
    public IsaEventDispatcher(Channel<IsaEvent> events) => _events = events;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var ev in _events.Reader.ReadAllAsync(ct))
        {
            // Idempotent processing keyed by ev.Id
        }
    }
}

Register the channel and dispatcher in DI. The webhook route writes events to the channel before responding 204.

Handle errors

Use an IExceptionFilter to translate SDK exceptions to HTTP responses. This keeps exception handling out of controllers.

// Filters/IsaExceptionFilter.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Isa.Sdk;

public class IsaExceptionFilter : IExceptionFilter
{
    public void OnException(ExceptionContext context)
    {
        switch (context.Exception)
        {
            case IsaNotActivatedException:
                context.Result = new ObjectResult(new {
                    error = new { code = "requires_activation" },
                }) { StatusCode = 401 };
                context.ExceptionHandled = true;
                break;

            case IsaIdempotencyConflictException ic:
                context.Result = new ObjectResult(new {
                    error = new { code = "idempotency_conflict", key = ic.Key },
                }) { StatusCode = 409 };
                context.ExceptionHandled = true;
                break;

            case IsaApiException api:
                context.Result = new ObjectResult(new {
                    error = new {
                        code = api.Code,
                        requestId = api.RequestId,
                        message = api.Message,
                    },
                }) { StatusCode = api.Status ?? 502 };
                context.ExceptionHandled = true;
                break;
        }
    }
}

Register the filter globally: builder.Services.AddControllers(o => o.Filters.Add<IsaExceptionFilter>());

Always log api.RequestId (format: req_01HZK2N5GQR9T8X4B6FJW3Y1AS). This is the anchor for support tickets.

Production checklist

  • Store ISA_TOKEN and Isa__WebhookSecret in your hosting platform's secret manager (never commit them).
  • Use UUID v4 idempotency keys. The SDK mints them by default. Bring your own via RunAsync(input, new IsaRequestOptions { IdempotencyKey = … }) to correlate retries across background jobs.
  • Echo result.RequestId in the X-Isa-Request-Id response header on every response.
  • On webhook routes, call Request.EnableBuffering() and read the raw body (never use model-bound JSON).
  • Use structured logging (Serilog or Microsoft.Extensions.Logging with JSON sink) and emit RequestId on every log line.

What's next

  • Authentication — bearer token rotation, test vs. live
  • Idempotency — how the SDK mints keys, when to bring your own
  • Webhooks — full signature format, retry schedule, dedupe strategy
  • C# Quickstart — the underlying SDK shapes