AWS Lambda (Go)
Run the ISA SDK in an AWS Lambda function written in Go: cold-start singleton, API Gateway proxy prequalify, and webhook verification.
AWS Lambda (Go)
This guide shows how to run the ISA SDK in an AWS Lambda function behind API Gateway. You'll build the client at package level (survives warm invocations), run v3 prequalify on each request, and verify webhooks in a separate function.
Prerequisites
- Go 1.22+, with
GOARCH=arm64orGOARCH=amd64to match your Lambda architecture. go get github.com/isa-sdk/sdkgo get github.com/aws/aws-lambda-go/lambdago get github.com/aws/aws-lambda-go/events- Your bearer token (
isa_test_…for test mode,isa_live_…for live) stored in AWS Secrets Manager and exposed to the function asISA_TOKEN. The token arrives by email after checkout; contact support to rotate it.
Package-level singleton
Lambda reuses the same process across warm invocations. Build the client at package level (in init()) so it survives warm invocations and you pay construction cost only once per cold start. WithBearerOptions pins the prequalify surface to v3 for uniform pricing.
// main.go
package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"github.com/isa-sdk/sdk"
"github.com/isa-sdk/sdk/catalog"
"github.com/isa-sdk/sdk/zyins"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
var isa *sdk.Isa
func init() {
var err error
isa, err = sdk.WithBearerOptions(sdk.BearerOptions{
Token: os.Getenv("ISA_TOKEN"), // e.g. isa_live_4fjK2nQ7mX1aB8sR9pZ3
APIVersion: map[string]string{"prequalify": "v3"},
})
if err != nil {
// init failures terminate the process before Lambda registers the handler.
panic("isa client: " + err.Error())
}
}
func main() {
lambda.Start(handler)
}
Prequalify handler
The handler reads the applicant profile from the API Gateway request body and runs a v3 prequalify. It projects each offer down to the fields a caller needs: carrier, product, category, and premium (as both display and integer cents).
The headline premium is the primary pricing row. Use zyins.OfferPremium to get it directly — you don't walk the pricing[] table by hand.
Product IDs: Use the prod_<uuid> identifiers from GET /v3/datasets?include=products. Pass specific products to quote them, or send products: [] on the wire to quote every product the account can sell (see Quoting all products).
func handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
if req.HTTPMethod != http.MethodPost || req.Path != "/prequalify" {
return respond(http.StatusNotFound, map[string]string{"error": "not found"})
}
var body struct {
DOB string `json:"dob"`
State string `json:"state"`
HeightInches int `json:"height_inches"`
WeightPounds int `json:"weight_pounds"`
FaceAmountCents int64 `json:"face_amount_cents"`
ProductIDs []string `json:"product_ids"`
}
if err := json.Unmarshal([]byte(req.Body), &body); err != nil {
return respond(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
height, err := zyins.NewHeightInches(body.HeightInches)
if err != nil {
return respond(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
weight, err := zyins.NewWeight(body.WeightPounds)
if err != nil {
return respond(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
coverage, err := zyins.NewFaceValueCoverage(int(body.FaceAmountCents / 100))
if err != nil {
return respond(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
selected := make([]catalog.Product, 0, len(body.ProductIDs))
for _, id := range body.ProductIDs {
product, ok := catalog.ByID(id)
if !ok {
return respond(http.StatusBadRequest, map[string]string{"error": "unknown product id " + id})
}
selected = append(selected, product)
}
products, err := zyins.NewProductSelectionOf(selected...)
if err != nil {
return respond(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
result, err := isa.Zyins.Prequalify.Run(ctx, &zyins.PrequalifyRequest{
Applicant: zyins.Applicant{
DOB: body.DOB,
Sex: zyins.SexMale,
Height: height,
Weight: weight,
State: zyins.State(body.State),
NicotineUse: zyins.NicotineUsageInput{LastUsed: zyins.NicotineNever},
},
Coverage: coverage,
Products: products,
})
if err != nil {
var apiErr *zyins.Error
if errors.As(err, &apiErr) {
return respond(http.StatusBadGateway, map[string]string{
"code": string(apiErr.Code),
"request_id": apiErr.RequestID,
})
}
return respond(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
type offerOut struct {
Carrier string `json:"carrier"`
Product string `json:"product"`
Category string `json:"category"`
PremiumDisplay string `json:"premium_display"`
PremiumCents int64 `json:"premium_cents"`
}
offers := make([]offerOut, 0, len(result.Plans))
for _, offer := range result.Plans {
premium := zyins.OfferPremium(offer)
if premium == nil {
continue // no qualifying pricing row for this applicant
}
var category string
for _, row := range offer.Pricing {
if row.Primary && row.Eligibility.Category != nil {
category = string(*row.Eligibility.Category)
break
}
}
offers = append(offers, offerOut{
Carrier: offer.Carrier.Name,
Product: offer.Product.Name,
Category: category,
PremiumDisplay: premium.Amount.Display, // carrier-formatted, never parse
PremiumCents: premium.Amount.Cents, // integer cents, safe for arithmetic
})
}
return respond(http.StatusOK, map[string]any{
"offers": offers,
"request_id": result.RequestID,
})
}
func respond(status int, body any) (events.APIGatewayProxyResponse, error) {
b, _ := json.Marshal(body)
return events.APIGatewayProxyResponse{
StatusCode: status,
Headers: map[string]string{"Content-Type": "application/json"},
Body: string(b),
}, nil
}
A response from the live endpoint, projected by the handler above:
{
"offers": [
{
"carrier": "Newbridge",
"product": "Final Expense",
"category": "immediate",
"premium_display": "126.43",
"premium_cents": 12643
}
],
"request_id": "req_01HZK2N5GQR9T8X4B6FJW3Y1AS"
}
Quoting all products
To quote every product the account can sell, send an empty products array — the server returns the full set (~248 offers). The typed Prequalify.Run path cannot express this yet, so issue the request directly:
curl -sS -X POST https://zyins.isaapi.com/v3/prequalify \
-H "Authorization: Bearer $ISA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d '{
"applicant": {"sex":"male","dob":"1962-04-18","height_inches":70,"weight_lbs":195,"nicotine":{"last_used":"never"}},
"coverage": {"face_amount_cents":2500000,"state":"NC"},
"products": []
}'
Webhook handler (separate function)
Deploy webhook reception as its own Lambda function. This separates two different workloads: prequalify is request–response, webhooks are asynchronous and have different timeout and retry profiles.
Verify the X-ZyINS-Signature header before you trust the body. That single header carries both fields as t=<unix-seconds>,v1=<hex>; parse both, recompute HMAC-SHA256(secret, "<t>.<raw body>"), and compare in constant time. The code injects the clock so the 5-minute tolerance check is testable.
// webhook/main.go
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)
const webhookToleranceSeconds = 300
// clock is the injectable time source; production passes time.Now, tests pass a fixed value.
type clock func() time.Time
func main() {
secret := os.Getenv("ISA_WEBHOOK_SECRET")
lambda.Start(webhookHandler(time.Now, secret))
}
func webhookHandler(now clock, secret string) func(context.Context, events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
return func(_ context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
raw := []byte(req.Body)
sig := req.Headers["x-zyins-signature"] // API Gateway lowercases header names
if err := verifySignature(now, raw, sig, secret); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, nil
}
var event map[string]any
if err := json.Unmarshal(raw, &event); err != nil {
return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, nil
}
// Dispatch on event["type"]; de-dupe on event["id"] for at-least-once delivery.
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK}, nil
}
}
func verifySignature(now clock, body []byte, header, secret string) error {
parts := make(map[string]string)
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, ok := parts["t"]
if !ok {
return fmt.Errorf("missing timestamp")
}
rawSig, ok := parts["v1"]
if !ok {
return fmt.Errorf("missing signature")
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("invalid timestamp: %w", err)
}
if math.Abs(float64(now().Unix()-tsInt)) > webhookToleranceSeconds {
return fmt.Errorf("timestamp out of tolerance")
}
payload := fmt.Sprintf("%s.%s", ts, body)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(expected), []byte(rawSig)) != 1 {
return fmt.Errorf("signature mismatch")
}
return nil
}
Cold start notes
Client initialization
init() runs on every cold start. sdk.WithBearerOptions constructs the client in memory with no network round-trip. Cold-start cost is binary size plus Go runtime init; the first request pays only network latency. The client has no per-request state and is safe to share across warm invocations.
Secrets
Use Lambda environment variables backed by AWS Secrets Manager — never hardcode. Rotate by replacing the secret in Secrets Manager; the token is emailed at checkout and rotated through support.
Powertools integration
If you use AWS Lambda Powertools, the isa client works cleanly as a plain value with no global state to reset between invocations.
Ergonomics
- Quoting all products currently drops to a raw request because
zyins.NewProductSelectionOfrejects an empty list andPrequalify.Runre-rejects an empty selection before the wire. Azyins.AllProducts()selector (or treating an emptyProductSelectionas "all") would let the typed path express it. - Webhook verification is hand-rolled here because no SDK ships a ZyINS webhook verifier today. The HMAC-SHA256 check above is the supported path — recompute over
<t>.<raw body>and compare thev1field in constant time.
See also
Updated about 9 hours ago