laravel-stedi maintained by cabuz
laravel-stedi
A Laravel SDK for the Stedi healthcare APIs.
Stedi is a healthcare clearinghouse with a JSON API: check a patient's insurance in real time, submit claims, track their status, receive remittances and enroll providers with payers. This package gives your Laravel app all of it:
use Cabuz\Stedi\Facades\Stedi;
$eligibility = Stedi::createEligibilityCheck([
'payerId' => '60054', // Aetna
'provider' => ['name' => ['organization' => 'Acme Clinic'], 'npi' => '1999999984'],
'subscriber' => [
'name' => ['person' => ['firstName' => 'Jane', 'lastName' => 'Doe']],
'memberId' => 'AETNA12345',
'dateOfBirth' => '2004-04-04',
],
'encounter' => ['services' => [['system' => 'STC', 'value' => '30']]],
]);
foreach ($eligibility['plans'] as $plan) { /* coverage, benefits... */ }
- Every public Stedi API: eligibility (real-time and batch), claims (professional, institutional, dental, attachments), claim status, remittances, coordination of benefits, insurance discovery, payers, enrollment, event destinations and Stedi Core. A test checks every method against Stedi's OpenAPI specs.
- Same names as the official SDK: one client, one method per operation
(
createEligibilityCheck,listClaims, ...), routed to the right Stedi host. api.md lists them all. - Webhooks built in: a route that verifies Stedi's signatures, drops
redeliveries and fires a
WebhookReceivedevent. - Safe retries: claim submissions get an idempotency key automatically, so a retried request never submits a claim twice.
- Patient data stays out of logs: optional logging records method, path, status and duration only.
- Laravel-native: config file, facade, Laravel's HTTP client, so
Http::fake()andStedi::fake()work in your tests.
This is a community package, not an official Stedi SDK. Stedi's own SDKs are TypeScript and Python only.
Requirements
- PHP 8.1+
- Laravel 10.10+, 11, 12 or 13
- A Stedi account and API key
Installation
composer require cabuz/laravel-stedi
Create an API key in the Stedi portal under Settings > API Keys and add it
to .env. A test key (test_...) answers mock requests
without contacting payers.
STEDI_API_KEY=your-key
Check that it works:
php artisan stedi:check
To change the defaults, publish the config file:
php artisan vendor:publish --tag=stedi-config
Usage
Call the operations on the Stedi facade, or inject Cabuz\Stedi\Stedi.
Method names are Stedi's operation names, the same as in the official
TypeScript and Python SDKs, and every Stedi
API is reached through the same client. Requests take arrays shaped like the
Stedi API reference; responses are the decoded
JSON as arrays, list methods return a CursorPage, and file
downloads (PDFs, CSVs, documents) return the raw contents.
Find a payer
$payers = Stedi::searchPayers(['query' => 'blue cross texas']);
foreach ($payers as $result) {
$result['payer']['displayName'];
$result['payer']['primaryPayerId']; // use as tradingPartnerServiceId
}
Stedi::getPayerRecord('HPQRS');
From the terminal: php artisan stedi:payers "blue cross".
Check eligibility
checkEligibility() runs a real-time check and answers the usual questions
without digging through the response:
$eligibility = Stedi::checkEligibility([...]); // same body as createEligibilityCheck()
if ($eligibility->hasErrors()) {
$eligibility->errors(); // the payer's AAA rejections: code, description, followupAction
}
$eligibility->isActive(); // active coverage for any service
$eligibility->isActive('98'); // ... for a Service Type Code
$eligibility->copay('98', 'IN_NETWORK'); // "25"
$eligibility->coinsurance('30', 'IN_NETWORK'); // "0.2" (20%)
$eligibility->deductible(network: 'IN_NETWORK'); // "1500" for the year
$eligibility->deductibleRemaining(network: 'IN_NETWORK', level: 'INDIVIDUAL');
$eligibility->outOfPocket();
$eligibility->outOfPocketRemaining();
$eligibility->benefits('limitations', service: '33'); // any category, filtered, as a collection
$eligibility['plans']; // the raw response is still there
A payer's AAA rejection (for example code 75, subscriber not found) comes
back from Stedi as a 400 with the usual body. checkEligibility() returns
it like any other answer, with the rejection in errors(); it only throws
InvalidRequestException when Stedi rejects the request before it reaches the
payer.
The filters are optional: service (an STC code), network (IN_NETWORK or
OUT_OF_NETWORK; entries for both networks match either) and level
(INDIVIDUAL, FAMILY, ...; INDIVIDUAL also matches the EMPLOYEE_ONLY
entries payers use for the subscriber's own cost sharing). Amounts come back
as strings, exactly as the payer sent them, or null when the payer sent
none. Payers often send several entries for the same service and network (one
per place of service, say); the helpers return the first, and benefits()
returns them all. Wrap a response you
already have with EligibilityResponse::from($array).
The other eligibility endpoints return arrays:
$response = Stedi::createEligibilityCheck([...]); // JSON eligibility check
Stedi::eligibilityCheck([...]); // the v3 (Change Healthcare-compatible) endpoint
Stedi::eligibilityRawX12Check(['x12' => '...']); // raw X12 270
Stedi::coordinationOfBenefits([...]);
Stedi::insuranceDiscoveryCheck([...]);
Batch checks run in the background. Queue PollEligibilityBatch to learn
when one finishes; it checks the batch every minute (without holding a worker
in between) and fires EligibilityBatchFinished:
use Cabuz\Stedi\Events\EligibilityBatchFinished;
use Cabuz\Stedi\Jobs\PollEligibilityBatch;
$batch = Stedi::batchEligibilityChecks(['items' => [...]]);
dispatch(new PollEligibilityBatch($batch['batchId'])); // interval: 60, timeout: 86400, apiKey: null
Event::listen(function (EligibilityBatchFinished $event) {
$event->status(); // COMPLETED, COMPLETED_WITH_ERRORS or VALIDATION_FAILED
Stedi::getBatchItems($event->batchId())->lazy()->each(fn ($item) => ...);
});
Pass apiKey: when the batch was submitted with another key; the job is
encrypted on the queue.
Submit claims
$claim = Stedi::claimsSubmission([...]); // professional (837P)
Stedi::institutionalClaimsSubmission([...]); // institutional (837I)
Stedi::dentalClaimsSubmission([...]); // dental (837D)
Stedi::createProfessionalClaimSubmission([...]); // CMS-1500-shaped JSON, like the official SDK
Submissions send an Idempotency-Key header. When you don't pass one, the
package generates it, so the automatic retry after a timeout or a 5xx reuses
it and Stedi doesn't create a second claim. Pass your own (e.g. derived from
your invoice ID) to make your own retries safe too:
Stedi::claimsSubmission($claim, idempotencyKey: "invoice-{$invoice->id}");
Then follow the claim:
Stedi::claimStatus([...]); // real-time 276/277
Stedi::listClaims(['status' => ['REJECTED']]);
Stedi::getClaimTimeline($claimId)->lazy()->all();
Stedi::convertReport835($transactionId); // remittance (ERA) as JSON
$pdf = Stedi::getElectronicRemittanceAdvicePdf($transactionId);
Enrollment, event destinations and Core
Every other operation follows the same pattern, for example
Stedi::createEnrollment([...]), Stedi::listProviders(),
Stedi::createEventDestination([...]) or Stedi::listTransactions(). See
api.md for the full list with the endpoint each method calls.
For anything else, the raw methods take the API name and path:
Stedi::request('healthcare', 'POST', '/some/new/endpoint', data: ['...' => '...']);
Stedi::send('payers', 'GET', '/payers/csv')->body();
Pagination
List methods return a CursorPage:
$page = Stedi::listClaims(['status' => ['REJECTED', 'DENIED'], 'pageSize' => 50]);
foreach ($page as $claim) { /* items on this page */ }
$page->hasMore();
$next = $page->nextPage();
// Every item across all pages, fetched on demand:
Stedi::listEnrollments()->lazy()->each(fn ($enrollment) => ...);
List filters can be PHP arrays and booleans; the package sends them the way
Stedi expects (status=REJECTED&status=DENIED, true/false).
Multiple API keys
Stedi::withApiKey($tenant->stedi_key)->createEligibilityCheck([...]);
withTimeout(), withRetries(), withHeaders() and withConfig() return a
copy with one setting changed the same way. Prefer them to changing
config('stedi.*') at runtime: the client reads the config once, when a
request or job first uses it, so a later config([...]) call only reaches the
next request or job.
Webhooks
Stedi delivers events (enrollment status changes, processed transactions, ...) to an event destination. The package registers a route for them:
-
Create a destination pointing at
https://your-app.com/stedi/webhook, in the portal or withStedi::createEventDestination([...]). -
Add its signing secret to
.env:STEDI_WEBHOOK_SECRET=whsec_... -
Listen for
Cabuz\Stedi\Events\WebhookReceived, preferably in a queued listener:use Cabuz\Stedi\Events\WebhookReceived; use Illuminate\Contracts\Queue\ShouldQueue; class HandleStediEvent implements ShouldQueue { public function handle(WebhookReceived $event): void { if ($event->is('enrollment.*')) { $enrollment = $event->fetch(); // Stedi::getEnrollment($event->resourceId()) // ... } } }Or listen for one type only. Every documented Stedi event type also fires its own class, which extends
WebhookReceived:Event type Class in Cabuz\Stedi\Eventsenrollment.activated,.rejected,.updatedEnrollmentActivated,EnrollmentRejected,EnrollmentUpdatedenrollment.task.assigned,.completed,.deletedEnrollmentTaskAssigned,EnrollmentTaskCompleted,EnrollmentTaskDeletedtransaction.processedTransactionProcessed(277CA, 835, ...)file.processed,file.failedFileProcessed,FileFailedevent.pingEventPingapp.installed,app.uninstalledAppInstalled,AppUninstalledpublic function handle(TransactionProcessed $event): void { $transaction = $event->fetch(); }
The route checks the Standard Webhooks
signature and the timestamp (5 minutes of tolerance), answers 400 to
anything unsigned, and acknowledges redeliveries of an event it already
handled without firing it again. If a listener throws, the delivery fails and
Stedi retries it later. Events are thin (type and resource ID), so fetch the
resource in your listener.
Listeners run before Stedi gets its answer, so a slow synchronous listener can
make the delivery time out and come back. Queued listeners answer at once; the
event only holds the payload array, so it queues as is. When your cache store
supports locks (Redis, database, Memcached, DynamoDB, file, array), a second
delivery of an event that is still being handled gets a 409 and Stedi
retries it later, so listeners never run twice for one event.
| Event method | Returns |
|---|---|
type() |
enrollment.activated, ... |
is('enrollment.*') |
whether the type matches |
resourceId(), resourceType() |
what the event is about |
isProduction() |
false for test-mode events |
fetch() |
the enrollment, transaction, execution or destination, from the API (fetch($client) for another key) |
$payload, $webhookId |
the full event and the delivery ID |
To rotate the secret without downtime, list both while the old one is still
valid: STEDI_WEBHOOK_SECRET=whsec_new,whsec_old. Set STEDI_WEBHOOK_PATH
to change the URL, or to an empty value to disable the route. To verify
signatures yourself, use Cabuz\Stedi\Webhooks\WebhookSignature::verify().
Errors
Every error extends Cabuz\Stedi\Exceptions\StediException:
| Exception | Status |
|---|---|
InvalidRequestException |
400, 413, 422 |
AuthenticationException |
401 |
PermissionDeniedException |
403, including features not available in test mode |
NotFoundException |
404 |
ConflictException |
409 |
RateLimitException |
429, after retries (retryAfter() gives the seconds) |
ServerException |
5xx |
ConnectionException |
Stedi could not be reached |
MissingApiKeyException |
No API key configured |
try {
Stedi::createEligibilityCheck($request);
} catch (InvalidRequestException $e) {
$e->error(); // Stedi's message
$e->errorCode(); // e.g. "access_denied", when Stedi sends one
$e->fieldErrors(); // [['path' => '/subscriber/memberId', 'message' => '...'], ...] (fieldList or errors)
$e->status(); // 400
$e->response; // the Illuminate\Http\Client\Response
}
Stedi::lastResponse() returns the raw response of the most recent request.
The client is bound as a scoped instance, so under Octane and in queue
workers each request or job starts with a fresh client and never sees another
one's response.
Retries
A request is retried up to 2 times (STEDI_RETRIES), with a delay that
doubles after each attempt and follows Retry-After when Stedi sends it. No
wait is longer than 10 seconds (STEDI_RETRY_MAX_DELAY, in milliseconds), so
a rate-limited request does not hold a web request for minutes:
- 429 Too Many Requests: retried for every request.
- 5xx and connection errors: retried for reads, and for writes sent with an idempotency key (every claim submission). Other writes, such as eligibility checks, are not repeated automatically.
Logging
STEDI_LOG=debug # debug, info, warning or error
STEDI_LOG_CHANNEL=stack # optional
debug logs every request (method, API, path, status, duration), info
retries, warning failed requests and error server and connection errors.
Request and response bodies, query strings and the API key are never logged,
because they can contain protected health information. Path parameters (IDs)
are logged.
Artisan commands
php artisan stedi:check # check the API key
php artisan stedi:payers "aetna" # look up payer IDs
Testing your app
Stedi::fake() keeps every request away from Stedi. Without stubs, each
operation answers with Stedi's own example response from its API reference:
an eligibility check comes back with active coverage, a payer search with a
page of payers, a claim submission accepted, and so on. Your code runs
against realistic data with one line:
use Cabuz\Stedi\Facades\Stedi;
Stedi::fake();
$eligibility = Stedi::checkEligibility([...]);
$eligibility->isActive(); // true
$eligibility->outOfPocket('30', 'IN_NETWORK'); // "8000"
Stub the requests your test cares about with {api}/{path} keys:
Stedi::fake([
'healthcare/eligibility-check' => ['errors' => [['code' => '75', 'description' => 'Subscriber/Insured Not Found']]],
'payers/payers/search*' => ['items' => []],
]);
// ... exercise your code ...
Stedi::assertSent('healthcare/eligibility-check', fn (array $body) => $body['subscriber']['memberId'] === 'AETNA12345');
Stedi::assertNotSent('claims/*');
Stedi::assertSentCount(1);
Stedi::assertNothingSent();
The callback gets the JSON body and the Illuminate\Http\Client\Request.
Stedi::sent('payers/*') returns the matching requests.
The API names are healthcare, claims, manager, payers, enrollment,
events and core. Patterns match the full URL, so use a trailing * for
requests with a query string. Example responses are a single page (no
nextPageToken), and operations without one, such as PDF downloads, return an
empty body. Stedi::fake(examples: false) answers every unstubbed request
with an empty 200 instead, as before 1.1.2.
To test your webhook listeners end to end, sign a payload with
WebhookSignature::sign():
use Cabuz\Stedi\Webhooks\WebhookSignature;
$body = json_encode(['id' => 'evt_1', 'type' => 'enrollment.activated', 'resource' => ['id' => 'enr_1', 'type' => 'enrollment']]);
$timestamp = time();
$this->call('POST', '/stedi/webhook', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_WEBHOOK_ID' => 'msg_1',
'HTTP_WEBHOOK_TIMESTAMP' => (string) $timestamp,
'HTTP_WEBHOOK_SIGNATURE' => WebhookSignature::sign($body, 'msg_1', $timestamp, config('stedi.webhook.secret')),
], content: $body)->assertOk();
Configuration
| Key | .env |
Default |
|---|---|---|
api_key |
STEDI_API_KEY |
none |
timeout |
STEDI_TIMEOUT |
60 seconds |
retries |
STEDI_RETRIES |
2 |
retry_delay |
STEDI_RETRY_DELAY |
500 ms |
retry_max_delay |
STEDI_RETRY_MAX_DELAY |
10000 ms |
headers |
none | [] |
endpoints |
none | Stedi's hosts; override one, e.g. for a proxy |
log |
STEDI_LOG |
none (off) |
log_channel |
STEDI_LOG_CHANNEL |
default channel |
webhook.path |
STEDI_WEBHOOK_PATH |
stedi/webhook |
webhook.secret |
STEDI_WEBHOOK_SECRET |
none |
webhook.tolerance |
STEDI_WEBHOOK_TOLERANCE |
300 seconds |
webhook.deduplicate |
none | true (uses your default cache store and its locks) |
webhook.middleware |
none | [] |
AI assistants
The package ships a Laravel Boost
guideline and skill. Run php artisan boost:install (or boost:update) and
select cabuz/laravel-stedi, and your AI assistant learns how to call Stedi
with this package.
Changelog
See CHANGELOG.md.
Contributing
See CONTRIBUTING.md. The methods are generated from
Stedi's OpenAPI specs: bin/update-specs refreshes them and bin/generate
rebuilds the methods, the facade docblock, api.md and the example
responses Stedi::fake() serves (resources/fakes.json).
Security
Please report security issues privately, as described in SECURITY.md.
License
The MIT License (MIT). See LICENSE.