npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zunta/connector-sdk

v0.3.1

Published

SDK for building Zunta clearing-house connector plugins: serveConnector (one call, any file), the EDI parser, per-operation schema declaration via generic()/edi(), and the zunta-connector build/serve/dev CLI with a local test page.

Readme

Zunta Connector SDK

This package (@zunta/connector-sdk) lets you build a connector plugin: a small program that links Zunta's claims system to one outside vendor — usually a clearing house (a company that receives medical claims and forwards them to insurance payers).

You only write the vendor-specific parts: "send this claim to my vendor", "ask my vendor for a claim's status", and so on. Zunta does everything else — deciding which claims go to your connector, storing every result, parsing EDI (EDI = the standard text format, also called X12, that medical claims travel in), retrying failed webhook deliveries, and even building and deploying your service.

How a plugin runs

  • Your plugin lives in its own git repository and runs as its own small HTTP service. Zunta's own Dokploy instance (Dokploy = Zunta's deployment platform) builds and deploys it straight from that repository.
  • No build files needed. The default build (a tool called railpack) detects a Node.js app, installs your dependencies, then runs your package.json build and start scripts.
  • Because the plugin is its own service, it can use anything it needs — its own dependencies, a headless browser, native modules — none of it touches Zunta's claims service.
  • Installing needs no Zunta claims deploy. A Zunta admin points the admin portal at your git repository, Zunta deploys it, and every later push to your branch redeploys it automatically.

Requirements

  • Node.js 20 or newer.

Quickstart

mkdir connector-myvendor && cd connector-myvendor
npm init -y
npm install @zunta/connector-sdk

A whole plugin is one function call — serveConnector(handlers) — made from any file, structured however you like: whatever module runs at startup and calls it IS the service. Create src/main.ts:

import {
  serveConnector,
  ok,
  fail,
  EConnectorErrorCode,
  EConnectorSubmitStatus,
} from '@zunta/connector-sdk';

serveConnector({
  async submitClaim(tree, { parser, config, logger }) {
    const edi = await parser.build(tree); // the submit-ready 837 EDI text
    const response = await fetch(`${config.baseUrl}/claims`, {
      method: 'POST',
      headers: { authorization: `Bearer ${config.apiKey}` },
      body: edi,
    });
    if (response.status === 400) {
      return fail(
        EConnectorErrorCode.CLEARING_HOUSE_REJECTED,
        await response.text(),
      );
    }
    if (!response.ok)
      throw new Error(`MyVendor answered HTTP ${response.status}`);
    logger.log('Claim submitted');
    return ok({ status: EConnectorSubmitStatus.ACCEPTED });
  },
});

Then add a start script to your package.json — "start": "node dist/main.js" — and that is the whole repository.

Three things you do not have to write:

  • No manifest file. Your service's GET /health endpoint automatically reports which operations you implement (derived from the handlers you pass, each with a declaration of what it takes and answers), plus a name and version taken from your package.json. Override the name with serveConnector({ ... }, { name: 'myvendor' }).
  • No fixed file layout. src/main.ts is only the CLI's default entry file. Keep your code in as many files as you like — the entry just has to end up calling serveConnector — and pass a different entry file as the last argument to any zunta-connector command.
  • No Dockerfile. Zunta builds your repository with railpack (see Deploying below).

Local development — zunta-connector dev

npx zunta-connector dev

This builds your connector in watch mode (edits rebuild and reload it automatically) and opens a local test page at http://localhost:4820 (set the PORT environment variable to use a different port). On the page, every operation runs against your real handlers: validate a claim, submit it, check its status, check eligibility, request a prior authorization, and simulate an inbound webhook.

How the page works:

  • There is no forced flow. Every operation is always available; one you have not implemented simply answers "No handler" (which is itself worth seeing).
  • The two claim operations (validate, submit) run on whatever is in the claim-tree box. Fill it by pasting or uploading a raw claim file — institutional (837I, the hospital claim form) or professional (837P, the doctor's-office claim form) — and parsing it. Re-upload and re-parse as often as you like; the tree is also yours to edit by hand.
  • Claim status, eligibility, authorization, and the webhook each have their own input boxes.
  • There are also boxes for the installation's config (what a handler reads as ctx.config) and creds (what a handler reads as ctx.creds — see The context below).
  • Every box is kept in your browser's localStorage, so a page reload loses nothing.
  • GET /health on the dev server answers exactly what the deployed service's /health would, operationSchemas included.

Connecting to a real Zunta claims environment

ctx.parser (and remittance reporting) talk to a real Zunta claims environment over HTTP. Setting that up is one step: ask a Zunta admin for the two development values and put them in a .env file in your repository. The CLI loads that file automatically, for dev and serve alike (real environment variables win over the file, and a missing file is simply skipped):

ZUNTA_CLAIMS_URL=https://<zunta-dev-claims>
PLUGIN_SERVICE_KEY=<the dev service key>
  • ZUNTA_CLAIMS_URL is the claims service's base URL and nothing more — no path, no /api/claims. The SDK adds the whole route path itself, so the URL can never be half-right.
  • zunta-connector dev tells you at startup whether the claims connection is wired, and prints exactly these two lines when it is not.
  • The dev service key is one shared key for all plugin developers; it only opens the claims service's connector-plugin routes (EDI parse/build, remittance reporting, queued-transaction results), nothing else.
  • Do not commit .env. Also add .zunta-connector/ and .zunta-dev/ (build artifacts the CLI writes) to your .gitignore.

The other two commands

  • npx zunta-connector serve — builds your entry once and runs it as-is: its own serveConnector call answers the exact HTTP surface the deployed service answers.
  • npx zunta-connector build — bundles the entry into dist/main.js (what your start script runs) and writes the schema manifest (see Declaring an operation's payloads below).

Deploying — Zunta does it

  1. Give a Zunta admin your git repository URL (and the branch — default main). Zunta creates the service on its own Dokploy instance, builds it, and starts routing claims to you.
  2. The admin portal then shows your plugin's deploy webhook URL. Paste it into your repository's webhook settings (push events), and every push to the configured branch redeploys the service automatically.

The build is railpack by default: it detects the Node app, installs your dependencies, runs npm run build, then npm start. Nothing to add to the repository. Only if your connector needs its own image (system packages, a headless browser) write a Dockerfile at the build path — example/Dockerfile in this package is a working template — and ask the admin to set the plugin's build type to dockerfile. Need chromium or another heavy dependency? Your repo, your package.json, and a Dockerfile if the build needs one — add whatever the connector needs.

The deployed service gets two environment variables injected — never hardcode them:

  • PLUGIN_SERVICE_KEY — the value of the apikey header that every route of your service requires (the SDK checks it for you).
  • ZUNTA_CLAIMS_URL — the base URL of the claims service that ctx.parser and ctx.remittances reach (the SDK adds each route path). serveConnector() refuses to start without it, so a deployed plugin can never parse anywhere but in Zunta.

PORT is not injected: serveConnector() listens on 8080 by default, which is exactly where the deployed service's address routes. Set it only when you run the service somewhere else.

Zunta rewrites the injected variables on every deploy, inside a block marked DO NOT EDIT — editing one of those there does not survive. Any other variable added to the service by hand is kept as it is, deploy after deploy.

The handlers — what you can implement

You call serveConnector({ ...handlers }). Every handler is optional — implement any subset; /health tells Zunta which operations you cover (plus webhooks when you implement onWebhook). A tree below means a fully typed parse tree — the claim or request as a plain, editable JavaScript object instead of raw EDI text.

| Handler | Gets | Answers | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | validateClaim(tree, ctx) | one claim's parse tree — institutional (837I) or professional (837P) | ok({ errors: [...] }) — the problems found, empty = valid (a ConnectorValidationResult, see Validating claims) | | submitClaim(tree, ctx) | one claim's parse tree — institutional (837I) or professional (837P) | ok(...) with a ClaimSubmitEdiResult: a required status verdict (EConnectorSubmitStatus.ACCEPTED / ACCEPTED_WITH_ERRORS / REJECTED), an optional errors list of problems noted alongside, and optionally what the clearing house answered — { dataType: ESubmitAckType.EDI_999, edi } (a raw 999 acknowledgment) or GENERIC JSON | | checkClaimStatus(request, ctx) | { claim, request276 } — the prepared claim tree (amount removed, dates widened) AND the 276 status request Zunta maps from it; use whichever your vendor needs | ok({ dataType: EClaimDataType.CLAIM_STATUS_RESPONSE_277, edi }), a 277CA, or GENERIC JSON + pcn — a ClaimStatusEdiResult | | eligibility(inquiry, ctx) | { tree, payer } — a built 270 eligibility-inquiry request tree | ok({ responseDataType: EEligibilityDataType.ELIGIBILITY_BENEFIT_271, edi }) or GENERIC JSON — an EligibilityEdiResult | | authorization(request, ctx) | { tree, payer } — a built 278 prior-authorization request tree | ok({ responseDataType: EAuthorizationDataType.SERVICES_REVIEW_278, edi }) or GENERIC JSON — an AuthorizationEdiResult | | onWebhook(request, ctx) | { headers, body, rawBody } exactly as your vendor sent them | nothing — push any remittance it carried with ctx.remittances.report(...); throw to make Zunta retry the delivery |

Every operation handler may also answer queued() instead of ok()/fail(...) — "accepted, the result comes later"; see Answering later below.

For the eligibility and authorization operations, the tree arrives with a default ISA/GS sender (the facility's NPI or tax id) and receiver (the payer id) already filled in — override them on the tree before building EDI when the clearing house expects specific interchange ids.

Either claim flavor. Every claim-shaped input takes either claim flavor — an institutional (837I) or a professional (837P) tree. validateClaim, submitClaim, the status pair's claim, parser.build(claim) and client.parser.parseAndSplit(edi) are all typed Institutional837IDocument | Professional837PDocument. Zunta's parse and build routes take both forms and tell them apart themselves (by the file's GS08 field, or by the tree). /health says so too — the claim operations declare 837i and 837p.

The submit answer. submitClaim's ok(...) payload (ClaimSubmitEdiResult) always declares the connector's own verdict in its required statusEConnectorSubmitStatus.ACCEPTED when the clearing house / payer took the submission, ACCEPTED_WITH_ERRORS when it took it but noted errors alongside, REJECTED when it turned it down. (Queuing is not a status: when the vendor answers later, return queued() instead — see Queued results.) The other two parts are optional:

  • errors — problems the clearing house reported alongside the answer, in the same { title?, description, isBlocking? } shape validateClaim answers with. Zunta stores each one on the claim as a post-submit validation error; an accepted submission carrying any is answered as "accepted with errors". Errors the 999 itself names are read out of it automatically, so only report what the 999 does not already say.
  • the clearing house's document{ dataType: ESubmitAckType.EDI_999, edi } with the raw 999 implementation-acknowledgment EDI (a 999 is the standard "we received your file and here is what we think of it" reply) — Zunta parses it and stores the whole document on the claim — or { dataType: ESubmitAckType.GENERIC, data } with the vendor's own JSON. Do not parse or interpret the 999 yourself — hand it over raw; a 999 that rejects the submission always wins, even over a declared ACCEPTED.

The status is required with no legacy window: an answer without one is a broken answer, and Zunta rejects the submission with that reason.

The status-check pair. checkClaimStatus receives the { claim, request276 } pair (a 276 is the standard claim-status-request form): Zunta builds the 276 from the prepared claim and hands you both — ctx.parser.build276(request.request276) turns the 276 into EDI for an X12 vendor, and request.claim carries every claim fact for a JSON vendor. Of the pair, request276 is the authoritative half: when a Zunta rule edits the outgoing request, the 276 always carries the edits, while claim may stay the prepared baseline (a 276-form edit has no way back into the claim tree). A vendor that must see rule edits reads them off request276.

Result and tree types

The result types and their tag enums are the very ones the Zunta claims service uses (ClaimSubmitEdiResult, ClaimStatusEdiResult, EligibilityEdiResult, AuthorizationEdiResult — all exported by the SDK), so what you return is exactly what Zunta stores. Tags are enum members, not plain strings — import the enum and use its member (EClaimDataType.GENERIC), which is what the example does.

Every tree is fully typed: a claim tree is an Institutional837IDocument or a Professional837PDocument, the status check's request276 is a StatusRequest276Document, the eligibility inquiry's tree is an EligibilityRequest270Document, the authorization request's tree is a ServicesReview278Document, and the parse methods return ClaimStatus277Document / Remittance835Document / EligibilityBenefit271Document / ServicesReview278Document — all exported by the SDK, together with each form's segment types under its namespace (I837I, I837P, I837D, Q276, S277, A277CA, R835, Q270, A271, PA278). So you can read any field off a tree with autocompletion, and when your vendor needs something changed, change the tree before building EDI from it — the tree is your handler's own copy, so mutate it directly:

if (tree.isa) tree.isa.usageIndicator_15 = 'T';
const edi = await parser.build(tree);

Rules of thumb

  • Do not interpret status codes or remittances. Hand over the raw EDI (or the vendor's JSON); Zunta parses and stores the whole document.
  • fail() with the matching EConnectorErrorCode when the vendor answered with a problem; throw when it was unreachable or errored — Zunta tells the user to retry. See Error codes below.
  • ctx.config holds this installation's settings from the admin portal (API keys, URLs). Never hardcode a secret.
  • Stay stateless. No files, no databases, no globals between calls — an instance can be replaced at any moment.

The context (ctx) — what every handler receives

Every handler's second argument carries:

  • ctx.config — this installation's config values (API keys, endpoints), as set in the Zunta admin portal.
  • ctx.creds — the credential values for this call, keyed by the route's credential-attribute keys: the values entered on the route itself, with the calling facility's own entered values merged over them. Empty when none are set — including on webhook deliveries, which no facility calls.
  • ctx.loggerlog / warn / error / debug, the same four levels as everywhere in Zunta.
  • ctx.parser — the EDI parser/builder (see The parser below).
  • ctx.remittances — the remittance reporter (see Remittances below).
  • ctx.transactions — the late-result submitter for queued() answers (see Answering later below).
  • ctx.transactionId — the Zunta-side id of this call (a number; absent on webhook deliveries and on the local dev page — a webhook is not an operation call).

Error codes — what fail() carries

fail(code, message, source?) names what went wrong with a code. The known codes are the EConnectorErrorCode enum — set the member matching what actually happened, because Zunta reacts to each differently:

| Code | Set it when | | ------------------------- | ----------------------------------------------------------------------------------- | | CLEARING_HOUSE_REJECTED | the clearing house answered and refused the request — shown to the user as final | | CREDENTIALS_EXPIRED | the installation's credentials in ctx.config are expired or no longer accepted | | SERVER_ERROR | the vendor errored on its own side (an HTTP 500-kind answer) — usually clears later | | GENERIC_ERROR | a failure no more specific code fits |

import { fail, EConnectorErrorCode } from '@zunta/connector-sdk';

if (response.status === 401) {
  return fail(
    EConnectorErrorCode.CREDENTIALS_EXPIRED,
    'MyVendor no longer accepts the configured API key.',
  );
}

More codes will be added over time. A code of your own (any string) is also allowed for a vendor-specific condition — Zunta treats one it does not know like GENERIC_ERROR. The optional third argument says where the error came from: 'payer' (the default — the clearing house or payer behind you) or 'connector' (your own code, e.g. missing config).

Validating claims — the validation result

validateClaim has a fixed result contract: answer ok(...) with every problem the validation found on the claim — a ConnectorValidationResult, whose errors is an array of ConnectorValidationError (both exported by the SDK):

| Field | Meaning | | ------------- | ----------------------------------------------------------- | | title | short human-readable summary of the problem | | description | the full explanation of the problem | | isBlocking | the claim must not be submitted until this problem is fixed |

An empty errors array means the claim is valid. Zunta stores each entry as a validation error on the claim, and a blocking one holds the claim back from submission:

serveConnector({
  async validateClaim(tree, ctx) {
    const findings = await myVendorScrub(await ctx.parser.build(tree));
    return ok({
      errors: findings.map((finding) => ({
        title: finding.code,
        description: finding.explanation,
        isBlocking: finding.severity === 'error',
      })),
    });
  },
});

Reserve fail(...) for when the validation itself could not run (the vendor was down, the credentials expired) — a claim with findings is a validation that SUCCEEDED at finding them. /health publishes this contract as the operation's response schema automatically.

A plain ok() with no payload is also accepted, but the payload contract above is the shape to use.

Answering later — queued()

Some vendors answer asynchronously: a nightly batch, a portal a person works through. Return queued() from any operation handler instead of ok()/fail(...) — "accepted, the result comes later". Zunta parks the call as a queued route transaction and moves on.

Every operation call carries the Zunta-side id of that transaction: the claims service POSTs { input, config, transactionId, creds } to /op/<operation>, and your handler reads the id as ctx.transactionId. Keep it wherever you track the vendor's pending work.

When the result finally exists, submit the very ok()/fail() envelope you would have returned:

serveConnector({
  async submitClaim(tree, ctx) {
    const edi = await ctx.parser.build(tree);
    // ...drop `edi` into the vendor's nightly batch, remembering ctx.transactionId...
    return queued();
  },
});

// Later — from a poll loop, a webhook, anywhere — even days later:
await ctx.transactions.submitResult(
  transactionId,
  ok({ status: EConnectorSubmitStatus.ACCEPTED }),
);

submitResult(transactionId, result) works from inside any handler via ctx.transactions, and outside a handler via new ClaimsClient().transactions (same ZUNTA_CLAIMS_URL / PLUGIN_SERVICE_KEY wiring as everything else — so it keeps working long after the process that answered queued() restarted). It is authenticated with your plugin's service key, and the transaction must still be awaiting a result (pending or queued — submitting the moment your handler returns queued() is fine): one that was already answered throws a readable Error.

Declaring an operation's payloads — generic() and edi()

/health tells Zunta not just which operations you implement but what each one takes and answers (operationSchemas, see The wire contract below). For the official handlers above that is automatic — their signatures say it all (a claim operation takes either claim flavor, an 837I or an 837P; eligibility a 270; and so on). Two wrappers cover everything else:

generic(handler) — your vendor speaks its own JSON instead of an official form. Write the handler with your own request/response types; the types ARE the whole declaration:

interface MyRequest {
  memberId: string;
  serviceTypeCodes?: string[];
}
interface MyResponse {
  eligible: boolean;
  copay?: number;
}

serveConnector({
  eligibility: generic(
    async (input: MyRequest, ctx): Promise<ConnectorResult<MyResponse>> => {
      // ...call your vendor's own API...
      return ok({ eligible: true, copay: 20 });
    },
  ),
});

zunta-connector build (and serve / dev) reads MyRequest / MyResponse off the TypeScript compiler and turns them into JSON Schemas (JSON Schema = a machine-readable description of a JSON shape) — primitives, literals, arrays, tuples, unions, optional properties, nested objects, records and enums all come out exact; anything more exotic falls back to the permissive {} schema with a one-line warning. The schemas land in .zunta-connector/manifest.json (add it to your .gitignore — it is regenerated on every build, including the deploy build), and the server reads that file to answer /health. If you run your entry directly instead (node / ts-node, no manifest), the server extracts on the fly when it can find the calling TypeScript file, and otherwise serves the types with schema: null plus a one-line hint to run zunta-connector build.

edi(type, handler) — your vendor wants an official EDI form the SDK has no bundled document type for (e.g. '2001'). The handler gets the raw EDI text; /health declares the operation's request as that form:

serveConnector({
  submitClaim: edi('2001', async (ediText, ctx) => {
    // ...deliver the raw EDI to the vendor...
    return ok({ status: EConnectorSubmitStatus.ACCEPTED });
  }),
});

Each operation only takes the forms relevant to it. 'generic' is allowed on every operation; the request forms edi() may declare are (the SDK exports the full table as CONNECTOR_OPERATION_ALLOWED_TYPES):

| Operation | Allowed request forms | | ------------------ | ------------------------------------------------ | | validateClaim | 837p, 837i, 837d, 2001, generic | | submitClaim | 837p, 837i, 837d, 2001, generic | | checkClaimStatus | 837p, 837i, 837d, 276, 2001, generic | | eligibility | 270, 2001, generic | | authorization | 278, 2001, generic |

edi() with a form its operation cannot take fails serveConnector() at startup with a message naming the allowed forms — you see it the moment you run zunta-connector dev, never in production. Both wrappers are for the operation handlers above only: wrapping onWebhook with one is ignored — a webhook delivery declares nothing (see Webhooks).

The parser (ctx.parser)

The same EDI machinery Zunta runs on — never ship your own X12 code. The SDK holds no EDI logic at all: ctx.parser is an HTTP client to Zunta itself (ZUNTA_CLAIMS_URL, authenticated with your PLUGIN_SERVICE_KEY), and every call is answered by the very parser Zunta uses for its own claims. So what you build and parse is what Zunta builds and parses — deployed and locally alike — and when Zunta changes its parser you change nothing.

  • build(tree) → submit-ready 837 EDI text, from an 837I or an 837P tree (Zunta tells them apart)
  • build276(request276Tree) → the 276 claim-status request EDI (checkClaimStatus is handed the tree ready to build)
  • build270(requestTree) → the 270 eligibility inquiry EDI
  • build278(requestTree) → the 278 prior-authorization services-review EDI
  • parseClaimStatusResponse(edi) → parsed 277 / 277CA document
  • parseRemittance(edi) → parsed 835 document
  • parseEligibilityResponse(edi) → parsed 271 document
  • parseAuthorizationResponse(edi) → parsed 278 document (either direction)

One extra method exists on new ClaimsClient().parser (not on ctx.parser): parseAndSplit(edi) turns one raw claim file — which may hold many claims — into one parse tree per claim; the local dev page uses it to fill the claim-tree box.

Remittances — pushed by you, whenever you obtain one

A remittance is the payer's payment/decision report for a claim. Remittances are push-based: your connector reports each result to Zunta the moment it obtains one — Zunta never asks for them, and no handler answers with them. Where the result came from does not matter: a webhook delivery, a poll loop, a scheduled sweep of the vendor's mailbox — the same one call reports it, and the same pipeline Zunta's built-in connectors use ingests it.

A reported result is one of three shapes (RemittanceResult):

  • { type: '277ca', edi } — a claim-acknowledgement status response, as raw EDI;
  • { type: '835', edi } — a remittance file (ERA — Electronic Remittance Advice), as raw EDI;
  • { type: 'generic', data, pcn } — the vendor's own JSON, plus the claim's Patient Control Number (pcn — Zunta's claim id) so Zunta knows which claim it belongs to.

Inside any handler, report with the context:

await ctx.remittances.report({ type: '835', edi });

Outside a handler — a poll loop is the classic case — build the same reporter yourself off a ClaimsClient (the SDK's one client to the Zunta claims service, the same thing ctx.parser and ctx.remittances come from); with no arguments it reads the very ZUNTA_CLAIMS_URL / PLUGIN_SERVICE_KEY the rest of the SDK runs on:

import { ClaimsClient } from '@zunta/connector-sdk';

const { remittances } = new ClaimsClient();
setInterval(async () => {
  // ...ask the vendor for new remittances...
  await remittances.report({ type: '835', edi });
}, 60_000);

Declare it. Supporting remittances is declared explicitly — pass the result types you push to serveConnector (any subset of '277ca', '835', 'generic'):

serveConnector({ ...handlers }, { remittances: { types: ['835'] } });

That declaration is what puts the remittance entry in /health's operationSchemas; without it Zunta treats your connector as not supporting remittances. A type outside the allowed three fails serveConnector() at startup, exactly like a bad edi() form.

Webhooks — plumbing only

A webhook is your vendor calling you: it POSTs a message to a URL you gave it whenever something happens. Implement onWebhook and Zunta gives your connector its own inbound path on the public API gateway:

POST <gateway>/webhook/connector-plugins/<your-slug>/webhook

Point your vendor at it. The gateway authenticates the delivery and queues it; every delivery lands in your onWebhook (retried with backoff on a throw). That is all a webhook is: plumbing that forwards your vendor's deliveries to you — Zunta is not otherwise involved, and your handler answers nothing. When a delivery carries a remittance, report it explicitly with ctx.remittances.report(...) (see Remittances above), the same as you would from a poll loop.

The shared secret — what it is and how to set it up. The gateway lets a delivery in only when its Authorization header carries the shared connector-plugin webhook secret. The header's whole value is compared to the secret verbatim — no Bearer prefix, no signing scheme — and a missing or wrong value is a 401: the delivery is rejected before your connector ever sees it.

It is one secret per environment, shared by every plugin — not something you create per connector. It lives on the public API gateway (zuntaV2-api) as the connectorPlugins.webhookSecret setting: in a deployed environment that is the gateway's CONNECTOR_PLUGINS_WEBHOOK_SECRET secret; against a locally-running gateway the value is literally secret.

Setting up a vendor is therefore two steps: get the target environment's secret value from whoever operates the gateway, and configure the vendor to send it as the Authorization header (the raw value, nothing prepended) on every delivery to your path above. Rotating it is a gateway-level change that breaks every vendor still sending the old value — never rotate it for one plugin.

Your handler never sees the secret: the gateway strips the Authorization header before forwarding, so there is nothing to re-check in onWebhook — your vendor's own signature (below) is your authenticity check.

Verify your vendor's signature yourself. The gateway's shared secret only keeps random traffic out — it is one value across all plugins and proves nothing about who sent a delivery. When your vendor signs its webhooks (an HMAC header — a cryptographic checksum computed with a key only you and the vendor share), check that signature in onWebhook over request.rawBody (the delivery's exact bytes, base64-encoded) — never over the re-serialized parsed body.

The wire contract (what the SDK server answers for you)

You never implement these — serveConnector / createRequestHandler do — but this is what Zunta calls:

GET  /health              → { name, version, operations, webhook, operationSchemas }
POST /op/<operation>      { input, config, transactionId, creds } → your handler's result envelope
POST /webhook             { headers, body, rawBody, config } → 200 {}

transactionId is the Zunta-side record of the operation call (your handler's ctx.transactionId) — what a queued() answer's late result is submitted against (see Answering later above). creds is what your handler reads as ctx.creds.

All routes require the apikey header when PLUGIN_SERVICE_KEY is set. The health answer is derived from your code — the operations are exactly the handlers you pass to serveConnector, and operationSchemas describes each one:

"operationSchemas": {
  "validateClaim": { "supported": true, "request": { "types": ["837i", "837p"] },
                     "response": { "types": ["generic"], "schema": { "type": "object", "...": "the validation result" } } },
  "submitClaim": { "supported": true, "request": { "types": ["837i", "837p"] }, "response": { "types": ["999", "generic"] } },
  "eligibility": { "supported": true,
                   "request":  { "types": ["generic"], "schema": { "type": "object", "...": "..." } },
                   "response": { "types": ["generic"], "schema": { "type": "object", "...": "..." } } },
  "remittance":  { "supported": true, "request": null, "response": { "types": ["835"] } }
}

types are the official form numbers ('837i', '270', '277ca', ...) and/or 'generic'; schema is the JSON Schema of a generic payload (from the manifest — null when none was generated). Every operation declares both its sides: the official validateClaim publishes the SDK's fixed validation-result schema (see Validating claims), and the official submitClaim declares 999 and generic responses with no fixed schema — a status verdict with an optional raw 999 acknowledgment or vendor JSON riding along. An operation you don't implement has no key in operationSchemas — Zunta reads a missing key as unsupported — and calling it answers 404 {"error": "No <operation> handler."} by design; Zunta reads that as "not implemented", never as an outage. The one exception to "key = handler" is remittance: it appears when the remittances option declares the result types you push (its request is null — push-based, nothing is ever requested), while the operations list and the webhook flag still say exactly what your handlers say.

serveConnector options

serveConnector(handlers, options?) accepts:

  • name — shown in /health and log lines; default: the package.json name without its scope.
  • remittances{ types: [...] }, the remittance declaration (see Remittances).
  • serviceKey — default: the PLUGIN_SERVICE_KEY environment variable; empty means no key check.
  • claimsUrl — the claims service's base URL; default: the ZUNTA_CLAIMS_URL environment variable (required — startup fails without one).
  • port — default: the PORT environment variable, else 8080.

When something other than serveConnector must own the HTTP server, use createRequestHandler(handlers, options?) — the whole service as one request handler, compatible with plain Node http and with express-style hosts.

Repo layout Zunta expects

package.json         with a "start": "node dist/main.js" script
src/main.ts          the CLI's default entry — any file whose startup calls serveConnector
                     (structure the rest however you like; pass another entry to override)
Dockerfile           optional — only for a `dockerfile` build type

Working on the SDK itself

The types the SDK shares with the Zunta claims service live once, in the Zunta common repo — so src/edi/, src/edi-records.ts and src/connector-contract.ts are gitignored copies, not checked in. The first two are the EDI type mirror; src/connector-contract.ts is the plugin↔claims wire contract (the operation and payload-type enums, the per-operation allowed-types maps, the validation-error shape and its JSON Schema, the error codes) — src/index.ts re-exports it under the SDK's own names (ConnectorOperation, ConnectorPayloadType, ... are plain-literal views of the contract's enums), so the two sides can never drift. In a fresh clone the copies don't exist yet and src/index.ts shows unresolved imports until you initialize the zunta/zuntaV2-common submodule and run:

npm run sync-shared-types

npm run build and npm publish do it for you (it deletes and recopies, so a stale copy can never leak into dist). Never edit those files by hand — change them in the common repo and re-sync. (The claims repo's own yarn sync-sdk-types runs this same script from the parent directory.)

typescript is a real dependency (not just a dev one) on purpose: the CLI's schema extraction runs the TypeScript compiler over the plugin's own sources, and a plugin author has TypeScript anyway. Plain tsc/esbuild only — no typia, nestia or ts-patch may ever be used here.

License

Proprietary — © Zunta, all rights reserved. The package is on a public registry only so installs need no npm auth; nobody may use it without Zunta's written authorization. See LICENSE.