Gin

Integrate the ISA SDK into a Gin application: one client on a dependency struct, a prequalify handler, and a signed webhook receiver.

Gin

This guide wires the ISA SDK into a Gin application. One *sdk.Isa client lives on a dependency struct and is injected into handlers, so there is no global state and no init(). The client is constructed once at startup; its namespaces share transport resources and are safe for concurrent use across Gin's request goroutines.

The shortest working call is the prequalify handler below: it reads a bearer token from the environment, runs the v3 prequalify engine for the John Doe persona (NC, DOB 1962-04-18, 5'10", 195 lb, no medications), and returns the qualified plans as JSON.

Prerequisites

  • Go 1.22 or newer.
  • go get github.com/isa-sdk/sdk and go get github.com/gin-gonic/gin.
  • A bearer token in ISA_TOKEN. Use a test token (isa_test_…) in development and a live token (isa_live_…) in production; both return the same response shape, and the token alone selects test or live mode. Tokens are emailed to you after checkout — contact support to rotate one.

Dependency struct

// internal/handler/handler.go
package handler

import (
    "github.com/gin-gonic/gin"
    "github.com/isa-sdk/sdk"
)

// Handler holds all application-wide dependencies.
type Handler struct {
    isa *sdk.Isa
}

func New(isa *sdk.Isa) *Handler {
    return &Handler{isa: isa}
}

func (h *Handler) RegisterRoutes(r *gin.Engine) {
    r.POST("/prequalify", h.Prequalify)
    r.POST("/webhooks/isa", h.Webhook)
}

Main setup

Construct the client once and hand it to the handler. Authentication is a single bearer token read from ISA_TOKEN; pinning the prequalify surface to v3 opts this client into the uniform v3 offer shape returned below.

// cmd/server/main.go
package main

import (
    "log"

    "github.com/gin-gonic/gin"
    "github.com/isa-sdk/sdk"
    "your-module/internal/handler"
)

func main() {
    // ISA_TOKEN is read from the environment when Token is empty.
    isa, err := sdk.WithBearerOptions(sdk.BearerOptions{
        APIVersion: map[string]string{"prequalify": "v3"},
    })
    if err != nil {
        log.Fatalf("isa: %v", err)
    }

    r := gin.Default()
    h := handler.New(isa)
    h.RegisterRoutes(r)

    if err := r.Run(":8080"); err != nil {
        log.Fatalf("server: %v", err)
    }
}

Prequalify handler

The handler binds the request body, runs prequalify for the applicant, and maps each offer to a compact JSON row. Each offer's headline premium and rate class come from the primary pricing row; zyins.OfferPremium returns that row's premium directly so you do not walk Pricing by hand.

Narrow the product roster by product id (prod_<uuid>), not by carrier slug. The id comes from Get the v3 datasets on the products row, and it selects the whole product family. The example pins one final-expense product; pass several ids to compare carriers in one call.

// internal/handler/prequalify.go
package handler

import (
    "errors"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/isa-sdk/sdk"
    "github.com/isa-sdk/sdk/catalog"
    "github.com/isa-sdk/sdk/zyins"
)

// newbridgeFinalExpenseID is one product id from GET /v3/datasets?include=products.
// Fetch ids at startup rather than hard-coding them in production.
const newbridgeFinalExpenseID = "prod_007e74bf-671c-41cc-be27-28cfd75fd5d2"

type prequalifyRequest struct {
    DOB          string `json:"dob"           binding:"required"`
    State        string `json:"state"         binding:"required"`
    HeightInches int    `json:"height_inches" binding:"required"`
    WeightPounds int    `json:"weight_pounds" binding:"required"`
    FaceAmount   int    `json:"face_amount"   binding:"required"`
}

func (h *Handler) Prequalify(c *gin.Context) {
    var req prequalifyRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    height, _ := zyins.NewHeightInches(req.HeightInches)
    weight, _ := zyins.NewWeight(req.WeightPounds)
    coverage, _ := zyins.NewFaceValueCoverage(req.FaceAmount)
    product, ok := catalog.ByID(newbridgeFinalExpenseID)
    if !ok {
        c.JSON(http.StatusInternalServerError, gin.H{"error": "unknown product id"})
        return
    }
    products, _ := zyins.NewProductSelectionOf(product)

    input := zyins.PrequalifyRequest{
        Applicant: zyins.Applicant{
            DOB:         req.DOB,
            Sex:         zyins.SexMale,
            Height:      height,
            Weight:      weight,
            State:       catalog.State(req.State),
            NicotineUse: zyins.NicotineUsageInput{LastUsed: zyins.NicotineNever},
        },
        Coverage: coverage,
        Products: products,
    }

    result, err := h.isa.Zyins.Prequalify.Run(c.Request.Context(), &input)
    if err != nil {
        var apiErr *zyins.Error
        if errors.As(err, &apiErr) {
            // 4xx errors are the caller's to fix; surface the code and request id.
            c.JSON(http.StatusBadGateway, gin.H{
                "code":       apiErr.Code,
                "request_id": apiErr.RequestID,
            })
            return
        }
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    type planOut struct {
        Carrier        string `json:"carrier"`
        Product        string `json:"product"`
        Category       string `json:"category"`
        PremiumDisplay string `json:"premium_display"`
        PremiumCents   int64  `json:"premium_cents"`
    }
    plans := make([]planOut, 0, len(result.Plans))
    for _, offer := range result.Plans {
        prem := zyins.OfferPremium(offer)
        if prem == nil {
            continue // no qualifying, priceable rate class for this applicant
        }
        var category string
        for _, row := range offer.Pricing {
            if row.Primary && row.Eligibility.Category != nil {
                category = string(*row.Eligibility.Category)
            }
        }
        plans = append(plans, planOut{
            Carrier:        offer.Carrier.Name,
            Product:        offer.Product.Name,
            Category:       category,
            PremiumDisplay: prem.Amount.Display,
            PremiumCents:   prem.Amount.Cents,
        })
    }
    c.JSON(http.StatusOK, gin.H{
        "plans":      plans,
        "request_id": result.RequestID,
    })
}

Webhook handler

ISA signs every webhook with an X-ZyINS-Signature header in the form t=<unix-seconds>,v1=<hex-hmac-sha256>. The signing string is <timestamp>.<raw-body>, so you must read the raw bytes before any JSON decode and compare them with a constant-time function.

Reject deliveries with timestamps outside the five-minute tolerance window. De-duplicate on the event's id because ISA delivers webhooks at least once. See the Webhooks guide for the retry schedule and full signature format.

// internal/handler/webhook.go
package handler

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"

    "github.com/gin-gonic/gin"
)

const (
    signatureHeader         = "X-ZyINS-Signature"
    webhookToleranceSeconds = 300
)

func (h *Handler) Webhook(c *gin.Context) {
    raw, err := io.ReadAll(c.Request.Body)
    if err != nil {
        c.Status(http.StatusBadRequest)
        return
    }

    sig := c.GetHeader(signatureHeader)
    if err := verifyWebhookSignature(raw, sig, os.Getenv("ISA_WEBHOOK_SECRET")); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid signature"})
        return
    }

    var event struct {
        ID   string `json:"id"`
        Type string `json:"type"`
    }
    if err := json.Unmarshal(raw, &event); err != nil {
        c.Status(http.StatusBadRequest)
        return
    }

    // De-dupe on event.ID, then dispatch on event.Type.
    c.Status(http.StatusOK)
}

func verifyWebhookSignature(body []byte, header, secret string) error {
    timestamp, macHex, err := parseSignatureHeader(header)
    if err != nil {
        return err
    }

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("parse webhook timestamp %q: %w", timestamp, err)
    }
    if delta := time.Now().Unix() - ts; delta > webhookToleranceSeconds || delta < -webhookToleranceSeconds {
        return fmt.Errorf("webhook timestamp %d is outside the %ds tolerance", ts, webhookToleranceSeconds)
    }

    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%s.%s", timestamp, body)
    expected := hex.EncodeToString(mac.Sum(nil))

    if subtle.ConstantTimeCompare([]byte(expected), []byte(macHex)) != 1 {
        return fmt.Errorf("webhook signature mismatch")
    }
    return nil
}

// parseSignatureHeader splits "t=<ts>,v1=<hex>" into its timestamp and hex parts.
func parseSignatureHeader(header string) (timestamp, macHex string, err error) {
    for _, part := range strings.Split(header, ",") {
        key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
        if !ok {
            continue
        }
        switch key {
        case "t":
            timestamp = value
        case "v1":
            macHex = value
        }
    }
    if timestamp == "" || macHex == "" {
        return "", "", fmt.Errorf("malformed %s header: %q", signatureHeader, header)
    }
    return timestamp, macHex, nil
}

See also

  • Go quickstart — install, authenticate, and run your first quote.
  • Authentication — bearer tokens, test versus live mode.
  • Webhooks — signature format, timestamp tolerance, and the retry schedule.
  • Error catalog — every code, its HTTP status, and how to remediate it.