ConceptsIntegration adapters

Integration adapters

What this is: the pattern by which the platform talks to any external vendor — wearables (Dexcom, Oura, WHOOP, Libre, Junction), labs (Junction Labs), telehealth (Rimo), email (Resend, Postmark), SMS (Twilio), payments (Stripe). Every external vendor has the same shape: one adapter package, one gateway service, registered through a provider registry.

Who it’s for: anyone adding a new vendor, debugging a flaky third-party, or trying to understand why @platform/integrations-adapter-junction and @platform/integrations-adapter-junction-labs coexist (one vendor, two capabilities — see Shared vendor, multiple capabilities).

What to read next: Service architecture, Events, Rate limits and circuit breakers.

Source ADR: 0042 — Channel adapters. Related: 0044 — Contacts provider registry, 0051 — Payment provider abstraction.

The rule (Architecture Decision Record 0042)

A vendor integration is a package (packages/<channel>-adapter-<vendor>/) imported by one gateway service. Apps never import adapters directly — they call the SDK, which calls the service, which calls the adapter.

This gives us:

  • Swappable vendors. Stripe → Adyen is a config change, not a refactor — the service holds the adapter contract; the adapter implements the vendor specifics.
  • One audit point. The service owns auth, audit logs, retries, and circuit breakers; adapters stay focused on protocol translation.
  • Testable in isolation. Adapters take a fetch in their constructor and can be tested with mocked HTTP. The service is tested against an in-memory adapter.

Anatomy of an adapter

A vendor adapter exposes:

  1. A client class (for example, JunctionLabsClient, RimoAdapterClient) — protocol translation, typed methods, vendor-specific errors mapped to a standard hierarchy.
  2. Zod schemas and TypeScript types — the on-the-wire shapes, re-used by the service for OpenAPI generation.
  3. Webhook utilities — signature validation, timestamp replay-window check, payload parsing into a discriminated union of event types.
  4. Mapping helpers — translate vendor shapes into platform-shaped contracts (for example, mapJunctionLabResultToRawBiomarkers).

What an adapter never contains:

  • HTTP route handlers (those live in the gateway service)
  • Database access (the service owns the schema)
  • Audit log writes (the service writes them around the adapter call)
  • Cross-vendor business logic (each adapter is single-vendor)

Channels and their gateway services

ChannelGateway serviceAdapter packages
Wearablesservices/integrationsintegrations-adapter-{junction,dexcom,oura,whoop,libre}
Bloodworkservices/clinicalintegrations-adapter-junction-labs
Telehealthservices/integrationsintegrations-adapter-rimo
Emailservices/commscomms-adapter-{resend,postmark}
SMSservices/commscomms-adapter-twilio
Web pushservices/commscomms-adapter-web-push
Marketingservices/commscomms-adapter-klaviyo
Paymentsservices/payments(provider abstraction per ADR-0051)

A channel can hold many adapters. A new vendor for an existing channel adds an adapter package. A new channel adds a contracts package and either a new gateway service or an extension of an existing one.

Shared vendor, multiple capabilities

Junction (Vital) provides both wearables and bloodwork. The two capabilities share a vendor account but are otherwise independent: different endpoints, different webhooks, different audit needs, different gateway services. The platform splits them:

  • @platform/integrations-adapter-junction — wearables, bundled into services/integrations
  • @platform/integrations-adapter-junction-labs — bloodwork, bundled into services/clinical

Split whenever two capabilities share only a vendor account but differ in:

  • Contracts (lab-order versus activity-summary)
  • Consumers (clinical staff versus the wearables sync cron)
  • Failure-mode handling (lab-order retry semantics differ from wearable backfill)

A conditional like if (capability === "labs") … inside an adapter is the signal that the split has not been made yet.

Webhooks

Webhooks are the inbound half of the adapter contract. Every adapter exports:

  • verify<Vendor>WebhookSignature(rawBody, header, secret) — hash-based message authentication code (HMAC) check
  • validate<Vendor>WebhookTimestamp(headers, toleranceSeconds?) — replay-window check
  • parse<Vendor>WebhookEvent(payload) — returns a discriminated-union event

Gateway services apply a requireScope or signature middleware before invoking the parser. The parsed event is republished as a platform event (vendor.capability.thing.v1) and other services subscribe through EventBridge. No service imports another vendor’s adapter to react to an event.

When not to reach for an adapter

  • Internal services calling internal services. Use the typed SDK package (@platform/sdk-<service>), not an adapter.
  • One-off scripts. Hit the vendor API directly from a script if the call lives nowhere else. Adapters exist to keep production code uniform; ad-hoc tooling does not need that uniformity.
  • Vendor SDKs that already provide a strong contract. Re-export them with a thin wrapper rather than re-implementing the type surface.

Common mistakes

  • Calling an adapter from another service. Call the gateway service over HTTP, or subscribe to its republished event.
  • Letting an adapter throw vendor errors directly to the route. Map them to a platform ErrorCode at the service boundary so OpenAPI consumers see a stable error shape.
  • Skipping the idempotency key on outbound vendor calls. Junction, Stripe, and similar vendors deduplicate by idempotency key. Use @platform/idempotency to generate one and pass it through.
  • Putting brand-specific logic in the adapter. Brand routing belongs in the service per Brands and multi-tenancy.

Reference implementations

See also