Skip to main content
A provider is the narrowest swap point in Voyant: a single implementation behind a contract interface. Storage backends, notification transports, and payment processors are all providers. Module code targets the contract, and the deployment chooses which implementation it gets. The platform never knows whether storage is R2 or S3, or whether email goes through Voyant or your own Resend account. Reach for a provider when the question is “how do I swap one implementation for another?” If you are adding a new bounded capability with its own records, build a module instead. If you are adding a route or hook to an existing module, build an extension. This guide shows the recipe for three providers: storage, notifications, and payments. The pattern is the same for all of them.

How providers reach the platform

A deployment assembles its providers into one typed container and passes it to createVoyantApp({ providers }). In the operator application that container is built by buildOperatorProviders() in src/api/composition.ts, and app.ts hands it straight to the platform:
buildOperatorProviders() returns a container that satisfies the platform’s FrameworkProviders contract. Each entry is a deployment-supplied resolver or factory the platform calls when it needs that capability. Swapping a provider means changing one entry in this container, nothing in the platform and nothing in the modules.

Swap a storage backend

Storage targets a single StorageProvider contract from @voyant-travel/storage/types. Picking a backend is choosing which factory creates that provider. See the Storage page for the full contract and each backend’s options.
1

Pick a built-in provider

The @voyant-travel/storage package ships three providers, each a factory that satisfies the same contract.
2

Wrap it in the service

Most deployments use one backend, so createStorageService(provider) wraps a single provider as a named StorageService with the same upload / delete / signedUrl / get surface.
3

Inject it through the provider container

Hand the factory to the deployment’s provider container so module code resolves storage from the deployment rather than constructing it. In the operator application this is the createOperatorDocumentStorage entry on the container returned by buildOperatorProviders().To move from R2 to S3, swap only the factory call:
No module changes, because every module already targets StorageProvider.

Swap a notification transport

Notifications use a list of providers rather than one. createNotificationService([...]) routes each send by its channel, and later providers override earlier ones on channel conflict. That ordering rule is the seam: register a local console sink first for development, then layer the real transports on top.
The selected providers are resolved at boot and handed to createNotificationService. Feature code picks a channel, never a vendor, so changing transport is a configuration change with no code edit.

Bring your own provider

The built-in providers are convenience, not a closed set. Any deployment can implement a contract against another vendor and register it in place of the shipped factory. The platform only sees the interface.

A custom storage backend

Implement the StorageProvider interface from @voyant-travel/storage/types, then pass your provider to createStorageService exactly like a built-in one:

A custom notification transport

Notifications make the bring-your-own path first-class. Implement NotificationProvider from @voyant-travel/notifications/types against any transport (raw Resend, Twilio, SES) and register it in the service list in place of the cloud providers. Because later providers win on channel conflict, you can keep the local sink for other channels and override just the one you replace:
Package it as a provider and select it as the deployment’s sms provider. Feature code keeps naming a channel, so nothing else changes. Dispatch by provider name with sendWith(name, payload) when you need to target a specific transport rather than route by channel.

Payments are a provider too

Payments follow the same shape, behind one contract rather than one integration per processor. @voyant-travel/payments defines the payment adapter contract (voyant.payment-adapter.v1), the provider catalog, and the remote transport. Processors are entries in that catalog: Netopia and Voyant Pay are providers, not bespoke wiring inside the booking flow. The contract is deliberately narrow — initiate, status, verify — and the platform’s vocabulary is its own rather than any processor’s. A provider maps its own stage names onto shared values:
  • Session state: pending, requires_redirect, processing, authorized, paid, failed, cancelled, expired.
  • Operation status: accepted, declined, pending, failed.
  • Checkout handoff: redirect or embedded, negotiated between what the provider supports and what the caller accepts.
  • Disputes: opened and under_review are open; won, lost and withdrawn are resolutions. Every card processor produces these, so the contract models them once.
  • Errors: CAPABILITY_NOT_SUPPORTED, IDEMPOTENCY_KEY_REUSED, INVALID_REQUEST, PROVIDER_UNAVAILABLE, ADAPTER_FAILURE.
A provider declares its PaymentAdapterCapabilities, and callers negotiate against them rather than assuming. That is what makes a processor swap a configuration change instead of a rewrite: finance code never learns which provider it is talking to. To support a different processor, implement the same contract and register the provider in the catalog. See Adapters and providers.

Next steps

Modules

Where a provider sits in the module, adapter, provider, extension taxonomy.

Storage

The full StorageProvider contract and each built-in backend’s options.

Adapters and providers

When to build which, and how the deployment graph selects it.

Configuration

How a deployment composes modules and wires the providers behind them.