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 row's key column is just its UUID; the module
column is stamped "integrations" and the JSONB body holds
{"provider", "name", "auth_type", "status", ...}. There is no
composite key shape — provider and name are pure JSONB.
Connections are referenced by the storage row's UUID. Names are pure user-chosen labels with no system semantics — any string is allowed (including spaces and duplicates within a provider); 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. Since names are no longer unique, these now return first-match.
The key column is the row's UUIDv7 — collisions are structurally
impossible. JSONB provider/name are pure data; corruption there
affects only this row's content, never routing.
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).
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 | term()}
Adds a new named connection for a provider.
This is the row-birth path. The row's UUIDv7 is generated up front and
used as both the uuid and the key column; provider and name live
in JSONB. Names are pure user-chosen labels with no character or
uniqueness restrictions — any non-empty string (after trim) is valid,
duplicates within a provider are allowed.
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.
Security — url must come from a trusted source
The integration's Bearer token is attached to every request this
function dispatches. If a caller passes a URL that came from
unvalidated user input, the token leaks to whatever host that URL
points at. Callers MUST validate the URL before invoking — pin to
a domain allowlist (the provider's own host, typically), enforce
https, and reject RFC1918 / loopback / link-local ranges.
Internal usage (OpenRouterClient.fetch_models/2, OAuth refresh,
userinfo lookups) builds URLs from the Providers registry, which is
hardcoded and therefore safe. The schema-level validate_base_url/1
guard in PhoenixKitAI.Endpoint covers the AI module's
operator-supplied base_url case. New callsites that take URLs
from anywhere else need their own guard before reaching this
function — there's no allowlist enforcement here.
@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.
@spec list_connections(String.t()) :: [ %{ uuid: String.t(), name: String.t(), data: map(), date_added: DateTime.t() | nil } ]
Lists all connections for a provider.
Returns a list of %{uuid, name, data, date_added} maps, sorted by
name (case-insensitive). Filters by module = "integrations" and
JSONB provider — provider and name both live in JSONB; the row's
uuid is the stable identifier. date_added is the row's creation
timestamp (UTC, second precision), useful for "Created N days ago"
display in UI pickers.
@spec list_integrations() :: [map()]
List all configured integrations (those that have saved data).
Order is determined by load_all_connections/1's map iteration,
which is alphabetical by provider key (Erlang Map order over
string keys) — NOT the registration order from Providers.all/0.
Within a provider, connections are sorted alphabetically by name
(case-insensitive). Callers that need a specific provider order
should walk Providers.all/0 themselves.
@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(), date_added: DateTime.t() | nil } ] }
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, date_added}], with every
requested provider key present (empty list when no connections exist).
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 | :not_configured | term()}
Renames a connection identified by uuid.
Updates only the JSONB name field; the storage key (= row uuid) is
untouched, so consumers that pinned to the uuid keep working across
the rename. Names are pure user-chosen labels; any non-empty string
is valid, duplicates within a provider are allowed.
No-ops when new_name (after trim) matches the current name.
Returns {:ok, new_data} on success.
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.
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.