Using the ISA SDK with Laravel
Bind the ISA SDK as a singleton in a service provider, run quotes from controllers and jobs, and verify webhooks.
Using the ISA SDK with Laravel
A Laravel 11 integration of the ISA PHP SDK. One client per worker
process bound through a service provider, queued idempotent quote jobs,
and a webhook controller that verifies HMAC signatures against the raw
request body.
The SDK authenticates with a single bearer token. After checkout, ISA
emails you a test key (isa_test_…) and a live key (isa_live_…); the
test key returns the same response shape as the live key but never
touches a real carrier. There is no dashboard yet — to rotate a key,
contact support.
Install
composer require isa-sdk/sdk
The package targets PHP 8.2+ and a PSR-18 HTTP client. Laravel 11 ships
guzzlehttp/guzzle by default — no extra work needed.
Add the token to your .env:
ISA_TOKEN=isa_test_4fjK2nQ7mX1aB8sR9pZ3
ISA_WEBHOOK_SECRET=whsec_…
And expose them through config/services.php:
<?php
// config/services.php
return [
// ...other services
'isa' => [
'token' => env('ISA_TOKEN'),
'webhook_secret' => env('ISA_WEBHOOK_SECRET'),
],
];
Initialize the client
Bind the SDK as a singleton in a service provider. One instance per
worker, shared across every request and queued job.
<?php
// app/Providers/IsaServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Isa\Sdk\Isa;
class IsaServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Isa::class, function ($app) {
return Isa::withBearer(
token: $app['config']->get('services.isa.token'),
);
});
}
}
Register the provider in bootstrap/providers.php:
return [
App\Providers\AppServiceProvider::class,
App\Providers\IsaServiceProvider::class,
];
The singleton resolves on first use. Long-running workers (Octane,
Horizon, queue:work) reuse the same instance for the worker's
lifetime; classic FPM still pays the construction cost once per request,
which is negligible (no network at construction). Inject Isa into any
controller or job constructor and Laravel resolves the singleton for
you.
Run a quote
The canonical happy path: John Doe, born 1962-04-18, in NC, 5'10",
195 lb, no conditions or medications, quoting every product at a
$25,000 face value. An empty products array means "all products" —
the engine returns one offer per product the applicant qualifies for.
Narrow to specific products by passing their product IDs (the
prod_… values from your datasets), not slugs.
<?php
// app/Http/Controllers/QuoteController.php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Isa\Sdk\Isa;
use Isa\Sdk\Catalog\State;
use Isa\Sdk\Zyins\Applicant;
use Isa\Sdk\Zyins\Coverage;
use Isa\Sdk\Zyins\Height;
use Isa\Sdk\Zyins\NicotineUsage;
use Isa\Sdk\Zyins\Prequalify\Input as PrequalifyInput;
use Isa\Sdk\Zyins\Sex;
use Isa\Sdk\Zyins\Weight;
class QuoteController extends Controller
{
public function __construct(private readonly Isa $isa) {}
public function run(): JsonResponse
{
$input = new PrequalifyInput(
applicant: new Applicant(
dob: '1962-04-18',
sex: Sex::Male,
height: Height::fromFeetInches(5, 10),
weight: Weight::fromPounds(195),
state: State::NorthCarolina,
nicotineUse: NicotineUsage::None,
),
coverage: Coverage::faceValue(25_000),
products: [], // all products; narrow by product ID
);
$result = $this->isa->zyins->prequalify->run($input);
return response()->json([
'plans' => array_map(
fn ($plan) => [
'carrier' => $plan->carrier->name,
'product' => $plan->product->name,
'premium' => $this->primaryRow($plan)->premium->amount->display, // verbatim carrier string
'cents' => $this->primaryRow($plan)->premium->amount->cents, // integer cents
'category' => $this->primaryRow($plan)->eligibility->category, // immediate | graded | rop
],
$result->data->plans,
),
'requestId' => $result->requestId,
])->header('X-Isa-Request-Id', $result->requestId);
}
/** The primary pricing row carries the headline premium and eligibility. */
private function primaryRow(object $plan): object
{
foreach ($plan->pricing as $row) {
if ($row->primary) {
return $row;
}
}
return $plan->pricing[0];
}
}
Each plan carries a carrier, a product, an optional death_benefit,
and a pricing array. Read the headline premium and eligibility from
the row flagged primary. Money is always a {cents, display} pair:
cents is the integer in US cents (canonical for arithmetic);
display is the verbatim carrier string — render it directly. Never do
floating-point math on premiums.
Idempotency in queued jobs
When a quote runs inside a job, the job's retry policy must not produce
duplicate prequalify calls with different bodies. Mint the idempotency
key when the job is dispatched and pass the same key on every retry.
Keys are UUID v4.
<?php
// app/Jobs/RunQuoteJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Ramsey\Uuid\Uuid;
use Isa\Sdk\Isa;
use Isa\Sdk\Zyins\Prequalify\Input as PrequalifyInput;
use Isa\Sdk\Zyins\RequestOptions;
class RunQuoteJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public readonly PrequalifyInput $input,
public readonly string $idempotencyKey,
) {}
public static function for(PrequalifyInput $input): self
{
return new self($input, Uuid::uuid4()->toString());
}
public function handle(Isa $isa): void
{
$result = $isa->zyins->prequalify->run(
$this->input,
RequestOptions::default()->withIdempotencyKey($this->idempotencyKey),
);
// persist $result keyed by $this->idempotencyKey
}
}
The job's idempotencyKey survives retries because it's part of the
serialized state. A retried handle() call replays the same key, so the
API returns the cached response without re-running underwriting. Reusing
a key with a different body raises
Isa\Sdk\Zyins\Exception\IsaIdempotencyConflictException — see the
error handler below.
Webhooks
The signature arrives in one header, X-ZyINS-Signature: t=<unix-seconds>,v1=<hex>.
Verification requires the raw request body — Laravel's
Request::getContent() returns exactly that. Recompute the HMAC over the
raw bytes, never the parsed JSON, because re-serializing changes the bytes
the signature was computed over. No SDK ships a ZyINS webhook verifier yet,
so parse the header and recompute by hand, comparing with hash_equals.
<?php
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class WebhookController extends Controller
{
private const TOLERANCE_SECONDS = 300;
public function rapidsign(Request $request): Response
{
$header = $request->header('X-ZyINS-Signature') ?? '';
if (!$this->verify($request->getContent(), $header, (string) config('services.isa.webhook_secret'))) {
return response('', 400);
}
$event = json_decode($request->getContent(), true);
// De-dupe on $event['id'] (at-least-once delivery).
dispatch(new \App\Jobs\HandleIsaEventJob($event));
return response()->noContent();
}
private function verify(string $rawBody, string $sigHeader, string $secret): bool
{
$fields = [];
foreach (explode(',', $sigHeader) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
$fields[$key] = $value;
}
$ts = (int) ($fields['t'] ?? '');
$receivedHex = $fields['v1'] ?? '';
if ($ts === 0 || $receivedHex === '' || abs(time() - $ts) > self::TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $receivedHex);
}
}
Register the route in routes/api.php and exclude it from CSRF
protection — webhooks are server-to-server.
Route::post('/webhooks/rapidsign', [WebhookController::class, 'rapidsign']);
Handle errors
Catch SDK exceptions in an exception handler. Every error subclasses
Isa\Sdk\Zyins\Exception\IsaException, which exposes a stable
machine-readable code() and the requestId() that correlates a
failed call to ISA's server logs. Match on code(), never on the
human-readable message — codes are stable, messages change.
<?php
// app/Exceptions/Handler.php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Isa\Sdk\Zyins\Exception\IsaException;
use Isa\Sdk\Zyins\Exception\IsaIdempotencyConflictException;
class Handler extends ExceptionHandler
{
public function register(): void
{
$this->renderable(function (IsaIdempotencyConflictException $e) {
return response()->json([
'error' => ['code' => $e->code(), 'key' => $e->getKey()],
], 409);
});
$this->renderable(function (IsaException $e) {
return response()->json([
'error' => [
'code' => $e->code(),
'requestId' => $e->requestId(),
'message' => $e->getMessage(),
],
], $e->httpStatus() ?? 502);
});
}
}
Always log $e->requestId() (for example
req_01HZK2N5GQR9T8X4B6FJW3Y1AS) — that's the anchor a support ticket
starts from.
Production checklist
ISA_TOKENandISA_WEBHOOK_SECRETset in.envper environment.
Use theisa_test_…key in staging and theisa_live_…key in
production. Never commit either.- Idempotency keys are UUID v4
(550e8400-e29b-41d4-a716-446655440000). The SDK mints one per call
by default; queued jobs mint at dispatch and reuse on retry. $result->requestIdreturned inX-Isa-Request-Idon every response.- Webhook routes excluded from CSRF and use
$request->getContent(),
never$request->all(). - A structured logger emits the request id alongside every request and
job log line.
What's next
- Authentication — bearer token model,
test vs. live keys - Idempotency — how the SDK mints keys, when to
bring your own - Webhooks — full signature format, retry schedule,
dedupe strategy - PHP Quickstart — the underlying SDK shapes
Updated about 9 hours ago