diff --git a/docs/push-notifications.md b/docs/push-notifications.md
new file mode 100644
index 000000000..cfb7edbe4
--- /dev/null
+++ b/docs/push-notifications.md
@@ -0,0 +1,112 @@
+# Push Notifications
+
+Conduit supports optional remote push wakeups through a Conduit-owned push proxy
+and Open WebUI's per-user notification webhooks.
+
+The app still uses the existing socket/local notification path for foreground
+and connected background behavior. Remote push is only activated when the app is
+built with push proxy and Firebase configuration.
+
+## Build Configuration
+
+Set these Dart defines for a build that should register remote push:
+
+```bash
+--dart-define=CONDUIT_PUSH_PROXY_BASE_URL=https://push.example.com
+--dart-define=CONDUIT_FIREBASE_API_KEY=...
+--dart-define=CONDUIT_FIREBASE_PROJECT_ID=...
+--dart-define=CONDUIT_FIREBASE_MESSAGING_SENDER_ID=...
+--dart-define=CONDUIT_FIREBASE_APP_ID_ANDROID=...
+--dart-define=CONDUIT_FIREBASE_APP_ID_IOS=...
+```
+
+Optional:
+
+```bash
+--dart-define=CONDUIT_FIREBASE_IOS_BUNDLE_ID=app.cogwheel.conduit
+--dart-define=CONDUIT_FIREBASE_ANDROID_CLIENT_ID=...
+```
+
+If these are omitted, the notification settings screen still works, but remote
+push registration is disabled and the app will not write webhook URLs.
+
+## Admin Flow
+
+Admins see an **Auto-configure server** switch in notification settings. Turning
+it on shows a disclosure dialog and then sets Open WebUI's
+`ENABLE_USER_WEBHOOKS` admin config flag through:
+
+```text
+GET /api/v1/auths/admin/config
+POST /api/v1/auths/admin/config
+```
+
+Conduit posts the full admin config payload back with only
+`ENABLE_USER_WEBHOOKS` changed, matching Open WebUI's admin-config update
+contract.
+
+## User Flow
+
+When a user enables system notifications, Conduit:
+
+1. Requests OS notification permission.
+2. Checks `/api/config` for `features.enable_user_webhooks`.
+3. Reads the device push token:
+ - Android: FCM registration token.
+ - iOS: APNs token exposed by Firebase Messaging.
+4. Registers the token with `CONDUIT_PUSH_PROXY_BASE_URL`.
+5. Writes the returned webhook URL to
+ `ui.notifications.webhook_url` in `/api/v1/users/user/settings/update`.
+
+Conduit refuses to overwrite an existing webhook URL unless it matches the URL
+that Conduit previously stored for that server. This prevents silently replacing
+a user's ntfy, Nextcloud, or custom Open WebUI webhook integration.
+
+## Push Proxy Protocol
+
+Registration request:
+
+```http
+POST /v1/installations
+content-type: application/json
+```
+
+```json
+{
+ "protocol_version": 1,
+ "app": "conduit",
+ "server_id": "server-id",
+ "server_url": "https://openwebui.example.com",
+ "user_id": "user-id",
+ "installation_id": "stable-device-installation-id",
+ "platform": "ios",
+ "token_type": "apns",
+ "push_token": "native-token"
+}
+```
+
+Registration response:
+
+```json
+{
+ "subscription_id": "subscription-id",
+ "webhook_url": "https://push.example.com/v1/openwebui/webhooks/..."
+}
+```
+
+Unregister:
+
+```http
+DELETE /v1/installations/{subscription_id}
+```
+
+Notification tap payloads should include:
+
+```json
+{
+ "conduit_kind": "chat_completion",
+ "conduit_source_id": "chat-id"
+}
+```
+
+Supported `conduit_kind` values are `chat_completion` and `channel_message`.
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 3bd0c1129..dca9cddb7 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -802,6 +802,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug;
APP_URL_SCHEME = "conduit-debug";
+ APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
@@ -1003,6 +1004,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit.x2662v5dt2.debug;
APP_URL_SCHEME = "conduit-debug";
+ APS_ENVIRONMENT = development;
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Debug";
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
@@ -1043,6 +1045,7 @@
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO;
APP_GROUP_ID = group.app.cogwheel.conduit;
APP_URL_SCHEME = conduit;
+ APS_ENVIRONMENT = production;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 7abe06364..1ed88ceae 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -107,6 +107,7 @@
audio
processing
+ remote-notification
voip
UILaunchStoryboardName
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 52fc60667..3dbd4bfe4 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -8,5 +8,7 @@
com.apple.developer.carplay-voice-based-conversation
+ aps-environment
+ $(APS_ENVIRONMENT)
diff --git a/lib/core/models/backend_config.dart b/lib/core/models/backend_config.dart
index eb53ab2f4..08ac36f28 100644
--- a/lib/core/models/backend_config.dart
+++ b/lib/core/models/backend_config.dart
@@ -123,6 +123,7 @@ class BackendConfig {
this.oauthProviders = const OAuthProviders(),
this.enableLdap = false,
this.enableLoginForm = true,
+ this.enableUserWebhooks = false,
});
/// Mirrors `features.enable_websocket` from OpenWebUI.
@@ -152,6 +153,9 @@ class BackendConfig {
/// Whether the standard login form (email/password) is enabled.
final bool enableLoginForm;
+ /// Whether Open WebUI allows per-user webhook URLs for notifications.
+ final bool enableUserWebhooks;
+
/// Whether SSO (OAuth) login is available.
bool get hasSsoEnabled => oauthProviders.hasAnyProvider;
@@ -173,6 +177,7 @@ class BackendConfig {
OAuthProviders? oauthProviders,
bool? enableLdap,
bool? enableLoginForm,
+ bool? enableUserWebhooks,
}) {
return BackendConfig(
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
@@ -191,6 +196,7 @@ class BackendConfig {
oauthProviders: oauthProviders ?? this.oauthProviders,
enableLdap: enableLdap ?? this.enableLdap,
enableLoginForm: enableLoginForm ?? this.enableLoginForm,
+ enableUserWebhooks: enableUserWebhooks ?? this.enableUserWebhooks,
);
}
@@ -231,6 +237,7 @@ class BackendConfig {
'oauth': {'providers': oauthProviders.toJson()},
'enable_ldap': enableLdap,
'enable_login_form': enableLoginForm,
+ 'enable_user_webhooks': enableUserWebhooks,
};
}
@@ -251,6 +258,7 @@ class BackendConfig {
OAuthProviders oauthProviders = const OAuthProviders();
bool enableLdap = false;
bool enableLoginForm = true;
+ bool enableUserWebhooks = false;
// Try canonical format first
final value = json['enable_websocket'];
@@ -311,6 +319,8 @@ class BackendConfig {
if (ldapValue is bool) enableLdap = ldapValue;
final loginFormValue = json['enable_login_form'];
if (loginFormValue is bool) enableLoginForm = loginFormValue;
+ final userWebhooksValue = json['enable_user_webhooks'];
+ if (userWebhooksValue is bool) enableUserWebhooks = userWebhooksValue;
// Fallback to nested format for backwards compatibility
final features = json['features'];
@@ -379,6 +389,10 @@ class BackendConfig {
if (nestedLdap is bool) enableLdap = nestedLdap;
final nestedLoginForm = features['enable_login_form'];
if (nestedLoginForm is bool) enableLoginForm = nestedLoginForm;
+ final nestedUserWebhooks = features['enable_user_webhooks'];
+ if (nestedUserWebhooks is bool) {
+ enableUserWebhooks = nestedUserWebhooks;
+ }
}
return BackendConfig(
@@ -398,6 +412,7 @@ class BackendConfig {
oauthProviders: oauthProviders,
enableLdap: enableLdap,
enableLoginForm: enableLoginForm,
+ enableUserWebhooks: enableUserWebhooks,
);
}
}
diff --git a/lib/core/models/server_user_settings.dart b/lib/core/models/server_user_settings.dart
index 9e53a62a6..e0f152a50 100644
--- a/lib/core/models/server_user_settings.dart
+++ b/lib/core/models/server_user_settings.dart
@@ -11,6 +11,7 @@ class ServerUserSettings {
this.notificationEnabled,
this.notificationSound,
this.notificationSoundAlways,
+ this.notificationWebhookUrl,
});
final String? systemPrompt;
@@ -25,6 +26,10 @@ class ServerUserSettings {
final bool? notificationSound;
final bool? notificationSoundAlways;
+ /// Open WebUI stores the per-user notification webhook at
+ /// `ui.notifications.webhook_url`.
+ final String? notificationWebhookUrl;
+
/// The user's preferred default model, if one is configured.
String? get defaultModelId =>
defaultModelIds.isEmpty ? null : defaultModelIds.first;
@@ -42,6 +47,9 @@ class ServerUserSettings {
notificationEnabled: _coerceBool(json['notificationEnabled']),
notificationSound: _coerceBool(json['notificationSound']),
notificationSoundAlways: _coerceBool(json['notificationSoundAlways']),
+ notificationWebhookUrl: _normalizeString(
+ _coerceJsonMap(ui?['notifications'])?['webhook_url'],
+ ),
);
}
@@ -53,6 +61,7 @@ class ServerUserSettings {
bool? notificationEnabled,
bool? notificationSound,
bool? notificationSoundAlways,
+ Object? notificationWebhookUrl = _serverUserSettingsUnset,
}) {
return ServerUserSettings(
systemPrompt: systemPrompt == _serverUserSettingsUnset
@@ -65,6 +74,9 @@ class ServerUserSettings {
notificationSound: notificationSound ?? this.notificationSound,
notificationSoundAlways:
notificationSoundAlways ?? this.notificationSoundAlways,
+ notificationWebhookUrl: notificationWebhookUrl == _serverUserSettingsUnset
+ ? this.notificationWebhookUrl
+ : notificationWebhookUrl as String?,
);
}
}
diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart
index 39e219f2b..a97c74d80 100644
--- a/lib/core/providers/app_startup_providers.dart
+++ b/lib/core/providers/app_startup_providers.dart
@@ -28,6 +28,7 @@ import '../../features/chat/providers/chat_providers.dart';
import '../../features/chat/providers/remap_route_sync_provider.dart';
import '../../features/notifications/providers/notification_socket_listener.dart';
import '../../features/notifications/services/local_notification_service.dart';
+import '../../features/notifications/services/remote_push_service.dart';
part 'app_startup_providers.g.dart';
@@ -117,6 +118,19 @@ Future _cleanupUserScopedProvidersAfterSignOut(Ref ref) async {
);
}),
);
+ unawaited(
+ ref
+ .read(remotePushServiceProvider)
+ .unregisterAllLocalSubscriptions()
+ .catchError((Object e, StackTrace st) {
+ DebugLogger.error(
+ 'failed to unregister push subscriptions on sign-out',
+ scope: 'notifications/push',
+ error: e,
+ stackTrace: st,
+ );
+ }),
+ );
ref.invalidate(notificationRouterProvider);
ref.invalidate(notificationSocketListenerProvider);
} catch (error, stackTrace) {
diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart
index 96192507f..d6a158c61 100644
--- a/lib/core/services/api_service.dart
+++ b/lib/core/services/api_service.dart
@@ -71,6 +71,88 @@ bool isTlsHandshakeFailureForTest(DioException error) {
message.contains('alert bad certificate');
}
+/// Raised when Conduit would overwrite a user-managed Open WebUI webhook.
+class UserNotificationWebhookConflictException implements Exception {
+ UserNotificationWebhookConflictException({
+ required this.currentWebhookUrl,
+ this.expectedWebhookUrl,
+ });
+
+ final String currentWebhookUrl;
+ final String? expectedWebhookUrl;
+
+ @override
+ String toString() =>
+ 'UserNotificationWebhookConflictException(current webhook differs)';
+}
+
+@visibleForTesting
+Map mergeUserNotificationWebhookSettingsForTest(
+ Map source, {
+ required String? webhookUrl,
+ String? expectedCurrentWebhookUrl,
+}) {
+ return _mergeUserNotificationWebhookSettings(
+ source,
+ webhookUrl: webhookUrl,
+ expectedCurrentWebhookUrl: expectedCurrentWebhookUrl,
+ );
+}
+
+Map _mergeUserNotificationWebhookSettings(
+ Map source, {
+ required String? webhookUrl,
+ String? expectedCurrentWebhookUrl,
+}) {
+ final settings = normalizeJsonLikeMap(source);
+ final ui = _coerceJsonMapForWebhookMerge(settings['ui']);
+ final notifications = _coerceJsonMapForWebhookMerge(ui['notifications']);
+ final currentWebhookUrl = _normalizeStringForWebhookMerge(
+ notifications['webhook_url'],
+ );
+ final expectedWebhookUrl = _normalizeStringForWebhookMerge(
+ expectedCurrentWebhookUrl,
+ );
+ final nextWebhookUrl = _normalizeStringForWebhookMerge(webhookUrl);
+
+ if (currentWebhookUrl != null && currentWebhookUrl != expectedWebhookUrl) {
+ throw UserNotificationWebhookConflictException(
+ currentWebhookUrl: currentWebhookUrl,
+ expectedWebhookUrl: expectedWebhookUrl,
+ );
+ }
+
+ if (nextWebhookUrl == null) {
+ notifications.remove('webhook_url');
+ } else {
+ notifications['webhook_url'] = nextWebhookUrl;
+ }
+
+ ui['notifications'] = notifications;
+ settings['ui'] = ui;
+ return settings;
+}
+
+Map _coerceJsonMapForWebhookMerge(Object? value) {
+ if (value is Map) {
+ return normalizeJsonLikeMap(value);
+ }
+ if (value is Map) {
+ return normalizeJsonLikeMap(
+ value.map((key, entry) => MapEntry(key?.toString() ?? '', entry)),
+ );
+ }
+ return {};
+}
+
+String? _normalizeStringForWebhookMerge(Object? value) {
+ if (value is! String) {
+ return null;
+ }
+ final trimmed = value.trim();
+ return trimmed.isEmpty ? null : trimmed;
+}
+
/// Get MIME type from file extension.
String? _getMimeType(String fileName) {
final ext = fileName.toLowerCase().split('.').last;
@@ -615,6 +697,23 @@ class ApiService {
}
}
+ Future