Centralized management of external service integrations.
Stores credentials (OAuth tokens, API keys, bot tokens, etc.) using the
existing PhoenixKit.Settings system with value_json JSONB storage.
Each integration is a JSON blob under a key like
"integration:google:default" (integration:{provider}:{name}).
Connections are referenced by the storage row's UUID. Names are pure user-chosen labels with no system semantics — they can be renamed or removed freely; consumer modules pin to UUIDs that survive renames.
Auth types supported
:oauth2— Google, Microsoft, Slack, etc. (client_id/secret + access/refresh tokens):api_key— OpenRouter, Stripe, SendGrid, etc. (single API key):key_secret— AWS, Twilio, etc. (access key + secret key):bot_token— Telegram, Discord, etc. (single bot token):credentials— SMTP, databases, etc. (freeform credential map)
Usage
Consumer modules (AI endpoints, document creator, etc.) store an integration's UUID on their own records and resolve credentials by UUID:
# Look up the row by uuid (the stable reference consumers store)
{:ok, %{provider: "openrouter", name: "default", data: data}} =
PhoenixKit.Integrations.get_integration_by_uuid(integration_uuid)
# Get credentials for API calls — accepts either a uuid or a
# `provider:name` shape
{:ok, creds} = PhoenixKit.Integrations.get_credentials(integration_uuid)
# => %{"access_token" => "ya29...", "token_type" => "Bearer", ...}
# Make an authenticated request with auto-refresh on 401
{:ok, response} =
PhoenixKit.Integrations.authenticated_request(integration_uuid, :get, url)Renaming and removing
Any connection can be renamed or removed — there's no privileged
"default" name. The storage row's UUID stays stable across renames,
so consumer references don't break:
{:ok, _} = PhoenixKit.Integrations.rename_connection(uuid, "work")
:ok = PhoenixKit.Integrations.remove_connection(uuid)API shape (uuid-strict)
Every operation past row creation takes the row's uuid. The only
exceptions are:
add_connection/3— row birth, no uuid exists yetget_integration/1,find_uuid_by_provider_name/1— read shims for legacymigrate_legacy/0callbacks that walk pre-uuid data shapes
The structural rule: "integration:{provider}:{name}" storage-key
construction happens only inside add_connection/3 (creation) and
module-side migrate_legacy/0 migrators (translation). Every other
caller routes by uuid, so a corrupted JSONB provider/name field
cannot leak into a new storage key.
Summary
Functions
Adds a new named connection for a provider.
Make an authenticated HTTP request with automatic token refresh on 401.
Build the OAuth authorization URL for a connection (by uuid).
Check if an integration is connected and has valid credentials.
Disconnect a connection (remove tokens, keep setup credentials).
Exchange an OAuth authorization code for tokens and save them on the
connection identified by uuid.
Resolve a provider:name-style reference to the storage row's uuid.
Get credentials for a provider, suitable for making API calls.
Get the full integration data for a provider.
Look up an integration row by its settings UUID and return a normalized
shape with provider, name, data, and the original uuid.
Lists all connections for a provider.
List all configured integrations (those that have saved data).
List all known providers.
Loads all connections for multiple providers in a single database query.
Persist the outcome of a connection check (manual or automatic) onto the integration record and broadcast a PubSub event when status changes.
Refresh an expired OAuth access token and save the new one.
Removes a connection by uuid.
Renames a connection identified by uuid.
Resolves a binary that may be EITHER an integration row's uuid OR a
provider:name string into the canonical row uuid.
Deprecated. Use PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0
from your host app's Application.start/2 instead.
Save setup credentials for an existing connection (referenced by uuid).
Returns the settings key for a provider connection.
Validate that a provider's credentials are working.
Probe a provider's API with in-memory credentials, without
persisting anything. Used by the integration form to let
operators test what they typed before committing — same HTTP
validation as validate_connection/2, but no storage row, no
last_validated_at stamp, no PubSub broadcast.
Functions
@spec add_connection(String.t(), String.t(), String.t() | nil) :: {:ok, %{uuid: String.t(), data: map()}} | {:error, :empty_name | :invalid_name | :already_exists | term()}
Adds a new named connection for a provider.
This is the row-birth path — the only place a new
integration:{provider}:{name} storage key is constructed. Every
other public API takes the row's uuid; callers can find it via the
returned :uuid or by listing the provider's connections.
The name can be any string alphanumeric with hyphens / underscores (e.g., "company-drive"), starting with an alphanumeric character.
Returns {:ok, %{uuid: uuid, data: data}} on success.
@spec authenticated_request(String.t(), atom(), String.t(), keyword()) :: {:ok, Req.Response.t()} | {:error, term()}
Make an authenticated HTTP request with automatic token refresh on 401.
For OAuth providers: adds Bearer token, retries with refreshed token on 401. For API key providers: adds Bearer token from the api_key. For bot token providers: returns credentials for the caller to use directly.
opts are passed through to Req.request/1.
@spec authorization_url(String.t(), String.t(), String.t() | nil, String.t() | nil) :: {:ok, String.t()} | {:error, term()}
Build the OAuth authorization URL for a connection (by uuid).
Accepts an optional state parameter for CSRF protection. Use
PhoenixKit.Integrations.OAuth.generate_state/0 to generate one,
store it in the session or socket assigns, and verify it when the
callback arrives.
Check if an integration is connected and has valid credentials.
Disconnect a connection (remove tokens, keep setup credentials).
For OAuth: removes access_token, refresh_token, keeps client_id/client_secret. For API key/bot token: removes the key entirely.
No-op when the uuid doesn't resolve (already gone).
@spec exchange_code(String.t(), String.t(), String.t(), String.t() | nil) :: {:ok, map()} | {:error, term()}
Exchange an OAuth authorization code for tokens and save them on the
connection identified by uuid.
@spec find_uuid_by_provider_name(String.t() | {String.t(), String.t()}) :: {:ok, String.t()} | {:error, :not_found | :invalid}
Resolve a provider:name-style reference to the storage row's uuid.
Used by consumer modules' migrate_legacy/0 implementations to walk
legacy name-string references and rewrite them to uuid references.
Accepts a few input shapes for convenience:
"openrouter:work"— full provider:name pair"openrouter"— bare provider, treated asprovider:default{"openrouter", "work"}— explicit tuple
Returns {:ok, uuid} if a matching row exists, {:error, :not_found}
if not, {:error, :invalid} for malformed input. Does NOT auto-pick
an arbitrary connection when multiple match — that's not the
caller's intent here.
Get credentials for a provider, suitable for making API calls.
Returns the full integration data map. The caller extracts what it needs
based on the auth type (e.g., "access_token" for OAuth, "api_key" for API key).
@spec get_integration(String.t()) :: {:ok, map()} | {:error, :not_configured | :invalid_provider_key}
Get the full integration data for a provider.
Returns the entire JSON blob including credentials, status, and metadata.
Misses return :not_configured (or :deleted for uuid input) — there's
no on-read legacy-shape migration in core anymore. Modules with legacy
data own their own migration via the migrate_legacy/0 callback on
PhoenixKit.Module (orchestrated by
PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0).
@spec get_integration_by_uuid(String.t()) :: {:ok, %{uuid: String.t(), provider: String.t(), name: String.t(), data: map()}} | {:error, :not_configured | :invalid_uuid}
Look up an integration row by its settings UUID and return a normalized
shape with provider, name, data, and the original uuid.
Used by the integration form LV (route /admin/settings/integrations/:uuid)
so the URL is stable across renames — the human-readable name lives in
the JSONB blob, the URL stays pinned to the row's storage UUID.
Lists all connections for a provider.
Returns a list of %{uuid: uuid, name: name, data: data} maps, with "default" first.
The uuid is the stable identifier for the settings row.
@spec list_integrations() :: [map()]
List all configured integrations (those that have saved data).
@spec list_providers() :: [map()]
List all known providers.
@spec load_all_connections([String.t()]) :: %{ required(String.t()) => [%{uuid: String.t(), name: String.t(), data: map()}] }
Loads all connections for multiple providers in a single database query.
More efficient than calling list_connections/1 in a loop.
Returns a map of provider_key => [%{uuid, name, data}].
Persist the outcome of a connection check (manual or automatic) onto the integration record and broadcast a PubSub event when status changes.
last_validated_at is always rewritten — it is the canonical
"moment of the last validation attempt" timestamp, and a manual
Test-Connection click that returns the same result must still
advance the field (otherwise the form's "Last tested N ago" reading
goes stale). Status and validation_status are merged in
unconditionally too — usually the same value as before, so it's a
no-op write at the JSONB level. The PubSub broadcast is gated on an
actual state change so high-frequency automatic paths (e.g. token
refresh failing on every API call) don't spam listing-LV reloads.
Refresh an expired OAuth access token and save the new one.
On failure, stamps the integration record with status: "error" and a
human-readable validation_status so the UI reflects the broken state
without waiting for an admin to click "Test Connection".
On success following a previously-errored state, auto-recovers the status
back to "connected".
Removes a connection by uuid.
Names are pure user-chosen labels — no privileged values. The user is
free to delete any connection; consumer modules that referenced the
deleted integration row will surface a :not_configured (or similar)
error on next use, which is the correct loud failure.
@spec rename_connection(String.t(), String.t(), String.t() | nil) :: {:ok, map()} | {:error, :empty_name | :invalid_name | :already_exists | :not_configured | term()}
Renames a connection identified by uuid.
Updates the row's key column in place (preserving the uuid) and
rewrites the JSONB name field. Consumers that pinned to the uuid
keep working across the rename — that's the whole point of uuid-based
references. Names are pure user-chosen labels; any name (including
the literal string "default") is valid.
No-ops when new_name matches the current name. Refuses if the new
name already exists for this provider, or if it doesn't match the
connection-name pattern.
Returns {:ok, new_data} on success, with the same JSONB body as
before but "name" rewritten.
Resolves a binary that may be EITHER an integration row's uuid OR a
provider:name string into the canonical row uuid.
This is the dual-input lookup that consumer modules' lazy-promotion
paths and migration sweeps converge on — code that reads a legacy
string from a column where the operator might have stuffed a uuid
pre-V107, or a provider:name shape pre-uuid-strict, or a bare
provider key. Each consumer used to copy the same regex + dispatch
pair into its own helper; this primitive centralises it so a future
provider doesn't tempt a third copy.
Returns {:ok, uuid} if the input resolves to a current row,
{:error, :not_found} if it parses cleanly but no matching row
exists, {:error, :invalid} for malformed input (empty string, nil,
non-binary).
Examples
iex> resolve_to_uuid("019b669c-3c9d-7256-8ed1-edbc6ae29703")
{:ok, "019b669c-3c9d-7256-8ed1-edbc6ae29703"} # already-uuid path
iex> resolve_to_uuid("openrouter:default")
{:ok, "..."} # provider:name path → find_uuid_by_provider_name
iex> resolve_to_uuid("openrouter")
{:ok, "..."} # bare provider, treated as provider:defaultSee find_uuid_by_provider_name/1 for the provider:name half of the
lookup. The split exists because that primitive doesn't handle the
"input is already a uuid" case — it'd treat "019b669c-..." as a
provider name and search integration:019b669c-...:default.
@spec run_legacy_migrations() :: :ok
Deprecated. Use PhoenixKit.ModuleRegistry.run_all_legacy_migrations/0
from your host app's Application.start/2 instead.
Each module that has legacy data now implements its own
migrate_legacy/0 callback. The orchestrator walks every registered
module and runs them all — same single entry point as before, but
modules own their own data shape.
Calling this delegates to the orchestrator for backwards compat.
Returns :ok regardless of per-module outcome (matches the previous
semantics of "best-effort, never crash boot").
@spec save_setup(String.t(), map(), String.t() | nil) :: {:ok, map()} | {:error, :not_configured | :invalid_uuid | term()}
Save setup credentials for an existing connection (referenced by uuid).
For OAuth providers, this saves client_id/client_secret. For API key providers, this saves the api_key. For bot token providers, this saves the bot_token.
Merges with existing data to preserve any previously obtained tokens. Sets status to "disconnected" if no runtime credentials exist yet.
The connection must exist (add_connection/3 is the row-birth path).
Returns {:error, :not_configured} if the uuid doesn't resolve.
Returns the settings key for a provider connection.
Accepts "google" (returns default connection key) or
"google:personal" (returns named connection key).
Examples
iex> PhoenixKit.Integrations.settings_key("google")
"integration:google:default"
iex> PhoenixKit.Integrations.settings_key("google:personal")
"integration:google:personal"
Validate that a provider's credentials are working.
For OAuth: calls the provider's userinfo endpoint.
For API key / bot token: calls the provider's validation endpoint if defined.
Returns :ok or {:error, reason}.
Probe a provider's API with in-memory credentials, without
persisting anything. Used by the integration form to let
operators test what they typed before committing — same HTTP
validation as validate_connection/2, but no storage row, no
last_validated_at stamp, no PubSub broadcast.
attrs is the same shape save_setup/3 accepts (e.g.
%{"api_key" => "..."} for api_key providers,
%{"client_id" => "...", "client_secret" => "..."} for OAuth).
OAuth providers without a saved access_token will return
{:error, "No access token"} — pre-save validation is most
useful for api_key / bot_token providers where the secret the
user just typed IS the credential.