NestJS
Integrate the ISA SDK into a NestJS application: module, injectable service, webhook controller.
NestJS
This guide wires the ISA SDK into a NestJS application using a module, an injectable IsaService, and a webhook controller. The SDK shares one Isa instance across your application lifetime via NestJS's singleton scope.
Prerequisites
- Node.js 20+, NestJS 10+
npm install isa-sdk- An ISA secret key in
ISA_TOKEN(a server-sideisa_test_…key while you build,isa_live_…in production). Your keys arrive by email after checkout — to rotate one, contact support. - A webhook signing secret in
ISA_WEBHOOK_SECRETif you mount the webhook controller.
The SDK authenticates with a bearer token; ISA_TOKEN is the only credential the application needs.
IsaModule
// isa/isa.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { IsaService } from './isa.service';
import { IsaController } from './isa.controller';
import { WebhookController } from './webhook.controller';
@Module({
imports: [ConfigModule],
providers: [IsaService],
controllers: [IsaController, WebhookController],
exports: [IsaService],
})
export class IsaModule {}
IsaService
onModuleInit builds one Isa instance from your bearer token and pins the prequalify surface to v3. Every call through this service returns the v3 plan shape. Since the instance carries no shared mutable state, the singleton is safe to call concurrently across requests.
// isa/isa.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Isa } from 'isa-sdk';
@Injectable()
export class IsaService implements OnModuleInit {
private isa!: Isa;
constructor(private readonly config: ConfigService) {}
async onModuleInit(): Promise<void> {
this.isa = await Isa.withBearer(
{ token: this.config.getOrThrow<string>('ISA_TOKEN') },
undefined,
{ apiVersion: { prequalify: 'v3' } },
);
}
get client(): Isa {
return this.isa;
}
}
Prequalify controller
The controller builds an applicant from the request body, runs prequalify, and maps each offer to a small client-facing row.
Each offer carries a pricing[] table with one row per rate class. Use offerPremium to get the best-qualifying premium — this is the single figure a list UI shows per product.
Construct Height and Weight through their factories to avoid raw unitless numbers, and use NicotineDuration for nicotine history. Use ProductSelection.of([...]) to narrow to specific products, or ProductSelection.byTypes([ProductClass.FinalExpense]) to select an entire product family.
// isa/isa.controller.ts
import {
Body,
Controller,
HttpCode,
HttpException,
HttpStatus,
Post,
} from '@nestjs/common';
import { IsaService } from './isa.service';
import {
Coverage,
Height,
IsaApiError,
IsaValidationError,
NicotineDuration,
Products,
ProductSelection,
Sex,
Weight,
offerPremium,
type Applicant,
type V3Offer,
} from 'isa-sdk';
interface PrequalifyBody {
dob: string;
state: string;
heightFeet: number;
heightInches: number;
weightPounds: number;
}
@Controller()
export class IsaController {
constructor(private readonly isaService: IsaService) {}
@Post('prequalify')
@HttpCode(HttpStatus.OK)
async prequalify(@Body() body: PrequalifyBody) {
const applicant: Applicant = {
dob: body.dob,
sex: Sex.Male,
height: Height.fromFeetInches(body.heightFeet, body.heightInches),
weight: Weight.fromPounds(body.weightPounds),
state: body.state,
nicotineUse: { lastUsed: NicotineDuration.Never },
};
try {
const result = await this.isaService.client.zyins.prequalify({
applicant,
coverage: Coverage.faceValue(25_000),
products: ProductSelection.of([Products.Fex.AetnaAccendo]),
});
const plans = result.data.plans as readonly V3Offer[];
return {
plans: plans.map((p) => {
const premium = offerPremium(p); // best-qualifying row, or null
return {
carrier: p.carrier.name,
product: p.product.name,
eligible: p.eligible, // qualifies for any rate class
premiumDisplay: premium?.amount.display ?? null, // server-formatted string
premiumCents: premium?.amount.cents ?? null, // integer cents
};
}),
requestId: result._requestId,
};
} catch (err) {
if (err instanceof IsaValidationError) {
throw new HttpException(
{ error: err.message, param: err.param },
HttpStatus.BAD_REQUEST,
);
}
if (err instanceof IsaApiError) {
throw new HttpException(
{ code: err.code, requestId: err.requestId },
HttpStatus.BAD_GATEWAY,
);
}
throw err;
}
}
}
Webhook controller
ISA signs every webhook with HMAC-SHA256 over <timestamp>.<raw body> and sends it in the X-ZyINS-Signature: t=<timestamp>,v1=<hex> header.
The controller recomputes the signature using your ISA_WEBHOOK_SECRET, compares it in constant time, and rejects any request outside the five-minute timestamp window. Always verify against the raw bytes — parsing the body first changes them and breaks the signature.
See the webhooks guide for the event catalog and retry schedule.
// isa/webhook.controller.ts
import {
BadRequestException,
Controller,
Headers,
HttpCode,
HttpStatus,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
import type { Request } from 'express';
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody: Buffer, header: string, secret: string): boolean {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2) as [string, string]),
);
const { t: ts, v1: sig } = parts;
if (!ts || !sig) return false;
const tsInt = parseInt(ts, 10);
if (Math.abs(Date.now() / 1_000 - tsInt) > TOLERANCE_SECONDS) return false;
const payload = `${ts}.${rawBody.toString('utf8')}`;
const expected = createHmac('sha256', secret).update(payload).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(sig, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
@Controller('webhooks')
export class WebhookController {
@Post('isa')
@HttpCode(HttpStatus.OK)
receiveWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('x-zyins-signature') sigHeader: string,
): void {
const secret = process.env['ISA_WEBHOOK_SECRET'] ?? '';
if (!verifySignature(req.rawBody!, sigHeader, secret)) {
throw new BadRequestException('invalid signature');
}
const event = JSON.parse(req.rawBody!.toString('utf8')) as { type: string };
// Dispatch on event.type
}
}
Enable raw body parsing in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { rawBody: true });
await app.listen(3000);
}
bootstrap();
See also
Updated about 9 hours ago