Skill: Adding Native Trigger Services
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
Architecture Overview
The native trigger system consists of:
- Database Layer - PostgreSQL tables and enum types
- Backend Rust Implementation - Core trait, handlers, and service modules in the
windmill-native-triggerscrate - Frontend Svelte Components - Configuration forms and UI components
Key Files
| Component | Path |
|---|---|
Core module with External trait | backend/windmill-native-triggers/src/lib.rs |
| Generic CRUD handlers | backend/windmill-native-triggers/src/handler.rs |
| Background sync logic | backend/windmill-native-triggers/src/sync.rs |
| OAuth/workspace integration | backend/windmill-native-triggers/src/workspace_integrations.rs |
| Re-export shim (windmill-api) | backend/windmill-api/src/native_triggers/mod.rs |
| TriggerKind enum | backend/windmill-common/src/triggers.rs |
| JobTriggerKind enum | backend/windmill-common/src/jobs.rs |
| Frontend service registry | frontend/src/lib/components/triggers/native/utils.ts |
| Frontend trigger utilities | frontend/src/lib/components/triggers/utils.ts |
| Trigger badges (icons + counts) | frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte |
| Workspace integrations UI | frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte |
| OAuth config form component | frontend/src/lib/components/workspaceSettings/OAuthClientConfig.svelte |
| OpenAPI spec | backend/windmill-api/openapi.yaml |
| Reference: Nextcloud module | backend/windmill-native-triggers/src/nextcloud/ |
| Reference: Google module | backend/windmill-native-triggers/src/google/ |
Crate Structure
The native trigger code lives in the windmill-native-triggers crate (backend/windmill-native-triggers/). The windmill-api crate re-exports everything via a shim:
rust1// backend/windmill-api/src/native_triggers/mod.rs 2pub use windmill_native_triggers::*;
All new service modules go in backend/windmill-native-triggers/src/.
Core Concepts
The External Trait
Every native trigger service implements the External trait defined in lib.rs:
rust1#[async_trait] 2pub trait External: Send + Sync + 'static { 3 // Associated types: 4 type ServiceConfig: Debug + DeserializeOwned + Serialize + Send + Sync; 5 type TriggerData: Debug + Serialize + Send + Sync; 6 type OAuthData: DeserializeOwned + Serialize + Clone + Send + Sync; 7 type CreateResponse: DeserializeOwned + Send + Sync; 8 9 // Constants: 10 const SUPPORT_WEBHOOK: bool; 11 const SERVICE_NAME: ServiceName; 12 const DISPLAY_NAME: &'static str; 13 const TOKEN_ENDPOINT: &'static str; 14 const REFRESH_ENDPOINT: &'static str; 15 const AUTH_ENDPOINT: &'static str; 16 17 // Required methods: 18 async fn create(&self, w_id, oauth_data, webhook_token, data, db, tx) -> Result<Self::CreateResponse>; 19 async fn update(&self, w_id, oauth_data, external_id, webhook_token, data, db, tx) -> Result<serde_json::Value>; 20 async fn get(&self, w_id, oauth_data, external_id, db, tx) -> Result<Self::TriggerData>; 21 async fn delete(&self, w_id, oauth_data, external_id, db, tx) -> Result<()>; 22 async fn exists(&self, w_id, oauth_data, external_id, db, tx) -> Result<bool>; 23 async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors); 24 fn external_id_and_metadata_from_response(&self, resp) -> (String, Option<serde_json::Value>); 25 26 // Methods with defaults: 27 async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned>; 28 fn service_config_from_create_response(&self, data, resp) -> Option<serde_json::Value>; 29 fn additional_routes(&self) -> axum::Router; 30 async fn http_client_request<T, B>(&self, url, method, workspace_id, tx, db, headers, body) -> Result<T>; 31}
Key design points:
update()returnsserde_json::Value- the resolved service_config to store. Each service is responsible for building the final config.maintain_triggers()- periodic background maintenance. Each service implements its own strategy (Nextcloud: reconcile with external state; Google: renew expiring channels).- No
list_all()in the trait - services that need it (Nextcloud) implement it privately; services that don't (Google) use different maintenance strategies. - No
get_external_id_from_trigger_data()orextract_service_config_from_trigger_data()- removed in favor of themaintain_triggerspattern.
Create Lifecycle: Two Paths
The create_native_trigger handler in handler.rs supports two creation flows, controlled by service_config_from_create_response():
Path A: Short (Google pattern) - service_config_from_create_response() returns Some(config):
create()registers on external serviceexternal_id_and_metadata_from_response()extracts the IDservice_config_from_create_response()builds the config directly from input data + response metadata- Stores trigger in DB -- done, no extra round-trip
Use this when the external_id is known before the create call (e.g., Google generates the channel_id as a UUID upfront and includes it in the webhook URL).
Path B: Long (Nextcloud pattern) - service_config_from_create_response() returns None (default):
create()registers on external service (webhook URL has no external_id yet)external_id_and_metadata_from_response()extracts the IDupdate()is called to fix the webhook URL with the now-known external_idupdate()returns the resolved service_config- Stores trigger in DB
Use this when the external_id is assigned by the remote service and the webhook URL needs to be corrected after creation.
OAuth Token Storage (Three-Table Pattern)
OAuth tokens are stored across three tables, NOT in workspace_integrations.oauth_data directly:
| Table | What's Stored |
|---|---|
workspace_integrations | oauth_data JSON with base_url, client_id, client_secret, instance_shared flag; resource_path pointing to the variable |
variable | Encrypted access_token (at the path stored in resource_path), linked to account via account column |
account | refresh_token, keyed by workspace_id + client (service name) + is_workspace_integration = true |
The decrypt_oauth_data() function in lib.rs assembles these into a unified struct:
rust1pub struct OAuthConfig { 2 pub base_url: String, 3 pub access_token: String, // decrypted from variable 4 pub refresh_token: Option<String>, // from account table 5 pub client_id: String, // from oauth_data or instance settings 6 pub client_secret: String, // from oauth_data or instance settings 7}
Instance-level sharing: when oauth_data.instance_shared == true, client_id and client_secret are read from global settings instead of workspace_integrations.
URL Resolution
The resolve_endpoint() helper handles both absolute and relative OAuth URLs:
rust1pub fn resolve_endpoint(base_url: &str, endpoint: &str) -> String { 2 if endpoint.starts_with("http://") || endpoint.starts_with("https://") { 3 endpoint.to_string() // Google: absolute URLs 4 } else { 5 format!("{}{}", base_url, endpoint) // Nextcloud: relative paths 6 } 7}
ServiceName Methods
ServiceName is the central registry enum. Each variant must implement these match arms:
| Method | Purpose |
|---|---|
as_str() | Lowercase identifier (e.g., "google") |
as_trigger_kind() | Maps to TriggerKind enum |
as_job_trigger_kind() | Maps to JobTriggerKind enum |
token_endpoint() | OAuth token endpoint (relative or absolute) |
auth_endpoint() | OAuth authorization endpoint |
oauth_scopes() | Space-separated OAuth scopes |
resource_type() | Resource type for token storage (e.g., "gworkspace") |
extra_auth_params() | Extra OAuth params (e.g., Google needs access_type=offline, prompt=consent) |
integration_service() | Maps to the workspace integration service (usually *self) |
TryFrom<String> | Parse from string |
Display | Delegates to as_str() |
Step-by-Step Implementation Guide
Step 1: Database Migration
Create a new migration file: backend/migrations/YYYYMMDDHHMMSS_newservice_trigger.up.sql
sql1-- Add the service to the native_trigger_service enum 2ALTER TYPE native_trigger_service ADD VALUE IF NOT EXISTS 'newservice'; 3 4-- Add to TRIGGER_KIND enum (used for trigger tracking) 5ALTER TYPE TRIGGER_KIND ADD VALUE IF NOT EXISTS 'newservice'; 6 7-- Add to job_trigger_kind enum (used for job tracking) 8ALTER TYPE job_trigger_kind ADD VALUE IF NOT EXISTS 'newservice';
Also create the corresponding down migration.
Step 2: Update windmill-common Enums
backend/windmill-common/src/triggers.rs
Add variant to TriggerKind enum, and update to_key() and fmt() implementations.
backend/windmill-common/src/jobs.rs
Add variant to JobTriggerKind enum and update the Display implementation.
Step 3: Backend Service Module
Create a new directory: backend/windmill-native-triggers/src/newservice/
mod.rs - Type Definitions
rust1use serde::{Deserialize, Serialize}; 2 3pub mod external; 4// pub mod routes; // Only if you need additional service-specific routes 5 6/// OAuth data deserialized from the three-table pattern. 7/// The actual structure is built by decrypt_oauth_data() from variable + account + workspace_integrations. 8#[derive(Debug, Clone, Deserialize, Serialize)] 9pub struct NewServiceOAuthData { 10 pub base_url: String, // from workspace_integrations.oauth_data 11 pub access_token: String, // decrypted from variable table 12 pub refresh_token: Option<String>, // from account table 13 // Note: client_id and client_secret are in OAuthConfig, not here 14 // unless the service needs them at runtime for API calls 15} 16 17/// Configuration provided by user when creating/updating a trigger. 18/// Stored as JSON in native_trigger.service_config. 19#[derive(Debug, Clone, Serialize, Deserialize)] 20#[serde(rename_all = "camelCase")] 21pub struct NewServiceConfig { 22 // Service-specific configuration fields 23 pub folder_path: String, 24 pub file_filter: Option<String>, 25} 26 27/// Data retrieved from the external service about a trigger. 28/// Returned by the get() method and shown in the UI. 29#[derive(Debug, Clone, Serialize, Deserialize)] 30#[serde(rename_all = "camelCase")] 31pub struct NewServiceTriggerData { 32 pub folder_path: String, 33 pub file_filter: Option<String>, 34 // Fields that shouldn't affect service_config comparison should use #[serde(skip_serializing)] 35} 36 37/// Response from external service when creating a trigger/webhook. 38#[derive(Debug, Deserialize)] 39pub struct CreateTriggerResponse { 40 pub id: String, 41} 42 43/// Handler struct (stateless, used for routing) 44#[derive(Copy, Clone)] 45pub struct NewService;
external.rs - External Trait Implementation
rust1use async_trait::async_trait; 2use reqwest::Method; 3use sqlx::PgConnection; 4use std::collections::HashMap; 5use windmill_common::{ 6 error::{Error, Result}, 7 BASE_URL, DB, 8}; 9 10use crate::{ 11 generate_webhook_service_url, External, NativeTrigger, NativeTriggerData, ServiceName, 12 sync::{SyncError, TriggerSyncInfo}, 13}; 14use super::{NewService, NewServiceConfig, NewServiceOAuthData, NewServiceTriggerData, CreateTriggerResponse}; 15 16#[async_trait] 17impl External for NewService { 18 type ServiceConfig = NewServiceConfig; 19 type TriggerData = NewServiceTriggerData; 20 type OAuthData = NewServiceOAuthData; 21 type CreateResponse = CreateTriggerResponse; 22 23 const SERVICE_NAME: ServiceName = ServiceName::NewService; 24 const DISPLAY_NAME: &'static str = "New Service"; 25 const SUPPORT_WEBHOOK: bool = true; 26 const TOKEN_ENDPOINT: &'static str = "/oauth/token"; 27 const REFRESH_ENDPOINT: &'static str = "/oauth/token"; 28 const AUTH_ENDPOINT: &'static str = "/oauth/authorize"; 29 30 async fn create( 31 &self, 32 w_id: &str, 33 oauth_data: &Self::OAuthData, 34 webhook_token: &str, 35 data: &NativeTriggerData<Self::ServiceConfig>, 36 db: &DB, 37 tx: &mut PgConnection, 38 ) -> Result<Self::CreateResponse> { 39 let base_url = &*BASE_URL.read().await; 40 41 // external_id is None during create (we get it from the response) 42 let webhook_url = generate_webhook_service_url( 43 base_url, w_id, &data.script_path, data.is_flow, 44 None, Self::SERVICE_NAME, webhook_token, 45 ); 46 47 let url = format!("{}/api/webhooks/create", oauth_data.base_url); 48 let payload = serde_json::json!({ 49 "callback_url": webhook_url, 50 "folder_path": data.service_config.folder_path, 51 }); 52 53 let response: CreateTriggerResponse = self 54 .http_client_request(&url, Method::POST, w_id, tx, db, None, Some(&payload)) 55 .await?; 56 57 Ok(response) 58 } 59 60 /// Update returns the resolved service_config as JSON. 61 /// For services using the update+get pattern, call self.get() and serialize. 62 async fn update( 63 &self, 64 w_id: &str, 65 oauth_data: &Self::OAuthData, 66 external_id: &str, 67 webhook_token: &str, 68 data: &NativeTriggerData<Self::ServiceConfig>, 69 db: &DB, 70 tx: &mut PgConnection, 71 ) -> Result<serde_json::Value> { 72 let base_url = &*BASE_URL.read().await; 73 74 let webhook_url = generate_webhook_service_url( 75 base_url, w_id, &data.script_path, data.is_flow, 76 Some(external_id), Self::SERVICE_NAME, webhook_token, 77 ); 78 79 let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); 80 let payload = serde_json::json!({ 81 "callback_url": webhook_url, 82 "folder_path": data.service_config.folder_path, 83 }); 84 85 let _: serde_json::Value = self 86 .http_client_request(&url, Method::PUT, w_id, tx, db, None, Some(&payload)) 87 .await?; 88 89 // Fetch back the updated state to get the resolved config 90 let trigger_data = self.get(w_id, oauth_data, external_id, db, tx).await?; 91 serde_json::to_value(&trigger_data) 92 .map_err(|e| Error::InternalErr(format!("Failed to serialize trigger data: {}", e))) 93 } 94 95 async fn get( 96 &self, 97 w_id: &str, 98 oauth_data: &Self::OAuthData, 99 external_id: &str, 100 db: &DB, 101 tx: &mut PgConnection, 102 ) -> Result<Self::TriggerData> { 103 let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); 104 self.http_client_request::<_, ()>(&url, Method::GET, w_id, tx, db, None, None).await 105 } 106 107 async fn delete( 108 &self, 109 w_id: &str, 110 oauth_data: &Self::OAuthData, 111 external_id: &str, 112 db: &DB, 113 tx: &mut PgConnection, 114 ) -> Result<()> { 115 let url = format!("{}/api/webhooks/{}", oauth_data.base_url, external_id); 116 let _: serde_json::Value = self 117 .http_client_request::<_, ()>(&url, Method::DELETE, w_id, tx, db, None, None) 118 .await 119 .or_else(|e| match &e { 120 Error::InternalErr(msg) if msg.contains("404") => Ok(serde_json::Value::Null), 121 _ => Err(e), 122 })?; 123 Ok(()) 124 } 125 126 async fn exists( 127 &self, 128 w_id: &str, 129 oauth_data: &Self::OAuthData, 130 external_id: &str, 131 db: &DB, 132 tx: &mut PgConnection, 133 ) -> Result<bool> { 134 match self.get(w_id, oauth_data, external_id, db, tx).await { 135 Ok(_) => Ok(true), 136 Err(Error::NotFound(_)) => Ok(false), 137 Err(e) => Err(e), 138 } 139 } 140 141 /// Background maintenance. Choose the right pattern for your service: 142 /// - For services with queryable external state: use reconcile_with_external_state() 143 /// - For channel-based services with expiration: implement renewal logic 144 async fn maintain_triggers( 145 &self, 146 db: &DB, 147 workspace_id: &str, 148 triggers: &[NativeTrigger], 149 oauth_data: &Self::OAuthData, 150 synced: &mut Vec<TriggerSyncInfo>, 151 errors: &mut Vec<SyncError>, 152 ) { 153 // Option A: Reconcile with external state (Nextcloud pattern) 154 // Fetch all triggers from external service and compare with DB 155 let external_triggers = match self.list_all(workspace_id, oauth_data, db).await { 156 Ok(triggers) => triggers, 157 Err(e) => { 158 errors.push(SyncError { 159 resource_path: format!("workspace:{}", workspace_id), 160 error_message: format!("Failed to list triggers: {}", e), 161 error_type: "api_error".to_string(), 162 }); 163 return; 164 } 165 }; 166 167 // Convert to (external_id, config_json) pairs 168 let external_pairs: Vec<(String, serde_json::Value)> = external_triggers 169 .into_iter() 170 .map(|t| (t.id.clone(), serde_json::to_value(&t).unwrap_or_default())) 171 .collect(); 172 173 crate::sync::reconcile_with_external_state( 174 db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, 175 ).await; 176 } 177 178 fn external_id_and_metadata_from_response( 179 &self, 180 resp: &Self::CreateResponse, 181 ) -> (String, Option<serde_json::Value>) { 182 (resp.id.clone(), None) 183 } 184 185 // service_config_from_create_response: NOT overridden (returns None). 186 // This means the handler uses the update+get pattern after create. 187 // Override and return Some(...) to skip the update+get cycle (Google pattern). 188} 189 190impl NewService { 191 /// Private helper to list all triggers from the external service. 192 async fn list_all( 193 &self, 194 w_id: &str, 195 oauth_data: &<Self as External>::OAuthData, 196 db: &DB, 197 ) -> Result<Vec<<Self as External>::TriggerData>> { 198 // Implementation depends on the external service's API 199 todo!() 200 } 201}
Step 4: Update lib.rs Registry
In backend/windmill-native-triggers/src/lib.rs:
rust1// Service modules - add new services here: 2#[cfg(feature = "native_trigger")] 3pub mod newservice; // <-- Add this 4 5// ServiceName enum - add variant: 6pub enum ServiceName { 7 Nextcloud, 8 Google, 9 NewService, // <-- Add this 10} 11 12// Then add match arms in ALL ServiceName methods: 13// as_str(), as_trigger_kind(), as_job_trigger_kind(), token_endpoint(), 14// auth_endpoint(), oauth_scopes(), resource_type(), extra_auth_params(), 15// integration_service(), TryFrom<String>, Display
Step 5: Update handler.rs Routes
In backend/windmill-native-triggers/src/handler.rs:
rust1pub fn generate_native_trigger_routers() -> Router { 2 // ... 3 #[cfg(feature = "native_trigger")] 4 { 5 use crate::newservice::NewService; 6 return router 7 .nest("/nextcloud", service_routes(NextCloud)) 8 .nest("/google", service_routes(Google)) 9 .nest("/newservice", service_routes(NewService)); // <-- Add this 10 } 11 // ... 12}
Step 6: Update sync.rs
In backend/windmill-native-triggers/src/sync.rs:
rust1pub async fn sync_all_triggers(db: &DB) -> Result<BackgroundSyncResult> { 2 // ... 3 #[cfg(feature = "native_trigger")] 4 { 5 use crate::newservice::NewService; 6 7 // ... existing service syncs ... 8 9 // New service sync 10 let (service_name, result) = sync_service_triggers(db, NewService).await; 11 total_synced += result.synced_triggers.len(); 12 total_errors += result.errors.len(); 13 service_results.insert(service_name, result); 14 } 15 // ... 16}
Step 7: Frontend Service Registry
In frontend/src/lib/components/triggers/native/utils.ts:
Add to NATIVE_TRIGGER_SERVICES, getTriggerIconName(), and getServiceIcon().
Step 8: Frontend Trigger Form Component
Create: frontend/src/lib/components/triggers/native/services/newservice/NewServiceTriggerForm.svelte
Step 9: Frontend Icon Component
Create: frontend/src/lib/components/icons/NewServiceIcon.svelte
Step 10: Update NativeTriggerEditor
Check frontend/src/lib/components/triggers/native/NativeTriggerEditor.svelte to ensure it dynamically loads form components based on service name.
Step 11: Workspace Integration UI
Add your service to the supportedServices map in frontend/src/lib/components/workspaceSettings/WorkspaceIntegrations.svelte:
typescript1const supportedServices: Record<string, ServiceConfig> = { 2 // ... existing services ... 3 newservice: { 4 name: 'newservice', 5 displayName: 'New Service', 6 description: 'Connect to New Service for triggers', 7 icon: NewServiceIcon, 8 docsUrl: 'https://www.windmill.dev/docs/integrations/newservice', 9 requiresBaseUrl: false, // false for cloud services, true for self-hosted 10 setupInstructions: [ 11 'Step 1: Create an OAuth app on the service', 12 'Step 2: Configure the redirect URI shown below', 13 'Step 3: Enter the client credentials below' 14 ] 15 } 16}
Step 12: Update frontend/src/lib/components/triggers/utils.ts
Update ALL of these maps/functions:
triggerIconMap- import and add icontriggerDisplayNamesMap- add display nametriggerTypeOrderinsortTriggers()- add typegetLightConfig()- add case for your servicegetTriggerLabel()- add case for your servicejobTriggerKinds- add to arraycountPropertyMap- add count propertytriggerSaveFunctions- add save function
Step 13: Update TriggersBadge Component
In frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte:
- Import the icon
- Add to
baseConfigwithcountKey(the dynamicavailableNativeServicesloop does NOT setcountKey) - Add to the
allTypesarray
Step 14: Update TriggersWrapper.svelte
In frontend/src/lib/components/triggers/TriggersWrapper.svelte:
Add a {:else if selectedTrigger.type === 'yourservice'} case that renders <NativeTriggersPanel service="yourservice" ...> with the same props pattern as the existing native trigger cases (e.g., nextcloud).
Step 15: Update AddTriggersButton.svelte
In frontend/src/lib/components/triggers/AddTriggersButton.svelte:
- Add
yourserviceAvailablestate variable - Add
setYourserviceState()async function usingisServiceAvailable('yourservice', $workspaceStore!) - Call it at module level
- Add a dropdown entry to
addTriggerItemswithhidden: !yourserviceAvailable
Step 16: Update TriggersEditor.svelte Delete Handling
In frontend/src/lib/components/triggers/TriggersEditor.svelte:
Add your service to the nativeTriggerServices map in deleteDeployedTrigger(). Native triggers use NativeTriggerService.deleteNativeTrigger({ workspace, serviceName, externalId }) instead of the standard path-based delete.
Step 17: Update OpenAPI Spec and Regenerate Types
Add to JobTriggerKind enum in backend/windmill-api/openapi.yaml, then:
bash1cd frontend && npm run generate-backend-client
Special Patterns
Unified Service with trigger_type (Google Pattern)
When a single service handles multiple trigger types (e.g., Google Drive + Calendar share OAuth and API patterns), use a single ServiceName variant with a discriminator field:
rust1pub enum GoogleTriggerType { Drive, Calendar } 2 3pub struct GoogleServiceConfig { 4 pub trigger_type: GoogleTriggerType, 5 // Drive-specific fields (only used when trigger_type = Drive) 6 pub resource_id: Option<String>, 7 pub resource_name: Option<String>, 8 // Calendar-specific fields (only used when trigger_type = Calendar) 9 pub calendar_id: Option<String>, 10 pub calendar_name: Option<String>, 11 // Metadata set after creation 12 pub google_resource_id: Option<String>, 13 pub expiration: Option<String>, 14}
Branch in trait methods based on trigger_type. Frontend uses a ToggleButtonGroup to switch between types. This keeps the codebase simpler (one service, one OAuth flow, one set of routes).
See backend/windmill-native-triggers/src/google/ for the reference implementation.
Skipping update+get After Create (Google Pattern)
Override service_config_from_create_response() to return Some(config) when the external_id is known before the create call:
rust1fn service_config_from_create_response( 2 &self, 3 data: &NativeTriggerData<Self::ServiceConfig>, 4 resp: &Self::CreateResponse, 5) -> Option<serde_json::Value> { 6 // Clone input config, add metadata from response 7 let mut config = data.service_config.clone(); 8 config.google_resource_id = Some(resp.resource_id.clone()); 9 config.expiration = Some(resp.expiration.clone()); 10 Some(serde_json::to_value(&config).unwrap()) 11}
Services with Absolute OAuth Endpoints (Google)
Unlike self-hosted services where OAuth endpoints are relative paths appended to base_url, services like Google have absolute URLs:
rust1// Nextcloud: relative paths 2ServiceName::Nextcloud => "/apps/oauth2/api/v1/token", 3// Google: absolute URLs 4ServiceName::Google => "https://oauth2.googleapis.com/token",
The resolve_endpoint() function handles both. For services with absolute endpoints:
base_urlcan be emptyrequiresBaseUrl: falsein the frontend workspace integration config- Add
extra_auth_params()if needed (Google requiresaccess_type=offlineandprompt=consent)
Channel-Based Push Notifications with Renewal (Google Pattern)
For services using expiring watch channels instead of persistent webhooks:
- Store expiration in
service_config(as part ofServiceConfig) - In
maintain_triggers(), implement renewal logic instead of usingreconcile_with_external_state():rust1async fn maintain_triggers(&self, db, workspace_id, triggers, oauth_data, synced, errors) { 2 for trigger in triggers { 3 if should_renew_channel(trigger) { 4 self.renew_channel(db, trigger, oauth_data).await; 5 } 6 } 7} - Renewal: best-effort stop old channel, create new one with same external_id, update service_config with new expiration
- Google example: Drive channels expire in 24h (renew when <1h left), Calendar channels expire in 7 days (renew when <1 day left)
reconcile_with_external_state (Nextcloud Pattern)
The reusable function in sync.rs compares external triggers with DB state:
- Triggers missing externally: sets error "Trigger no longer exists on external service"
- Triggers present externally: clears errors, updates service_config if it differs
Usage in maintain_triggers():
rust1let external_pairs: Vec<(String, serde_json::Value)> = /* fetch from external */; 2crate::sync::reconcile_with_external_state( 3 db, workspace_id, Self::SERVICE_NAME, triggers, &external_pairs, synced, errors, 4).await;
Webhook Payload Processing
Override prepare_webhook() to parse service-specific payloads into script/flow args:
rust1async fn prepare_webhook(&self, db, w_id, headers, body, script_path, is_flow) -> Result<PushArgsOwned> { 2 let mut args = HashMap::new(); 3 args.insert("event_type".to_string(), Box::new(headers.get("x-event-type").cloned()) as _); 4 args.insert("payload".to_string(), Box::new(serde_json::from_str::<serde_json::Value>(&body)?) as _); 5 Ok(PushArgsOwned { extra: None, args }) 6}
Then register in prepare_native_trigger_args() in lib.rs:
rust1pub async fn prepare_native_trigger_args(service_name, db, w_id, headers, body) -> Result<Option<PushArgsOwned>> { 2 match service_name { 3 ServiceName::Google => { /* ... */ Ok(Some(args)) } 4 ServiceName::NewService => { /* ... */ Ok(Some(args)) } 5 ServiceName::Nextcloud => Ok(None), // Uses default body parsing 6 } 7}
Instance-Level OAuth Credentials
When workspace_integrations.oauth_data.instance_shared == true, decrypt_oauth_data() reads client_id and client_secret from instance-level global settings instead of workspace-level. This allows admins to share OAuth app credentials across workspaces.
The frontend handles this via the generate_instance_connect_url endpoint in workspace_integrations.rs.
Testing Checklist
- Database migration runs successfully
-
cargo check -p windmill-native-triggers --features native_triggerpasses -
npx svelte-check --threshold errorpasses (in frontend/) - Service appears in workspace integrations list
- OAuth flow completes successfully
- Can create a new trigger
- Can view trigger details
- Can update trigger configuration
- Can delete trigger
- Webhook receives and processes payloads
- Background sync works correctly (reconciliation or channel renewal)
- Error handling works (expired tokens, service unavailable)
Reference Implementations
Nextcloud (Self-Hosted, Update+Get Pattern)
| File | Purpose |
|---|---|
nextcloud/mod.rs | Types: NextCloudOAuthData, NextcloudServiceConfig, NextCloudTriggerData |
nextcloud/external.rs | External trait: uses update+get pattern, reconcile_with_external_state for sync |
nextcloud/routes.rs | Additional route: GET /events |
Key patterns: relative OAuth endpoints, base_url required, list_all + reconcile for sync, update returns JSON from get().
Google (Cloud, Unified Service, Short Create)
| File | Purpose |
|---|---|
google/mod.rs | Types: GoogleServiceConfig with trigger_type discriminator, GoogleTriggerType enum |
google/external.rs | External trait: overrides service_config_from_create_response, channel renewal for sync |
google/routes.rs | Additional routes: GET /calendars, GET /drive/files, GET /drive/shared_drives |
Key patterns: absolute OAuth endpoints, empty base_url, trigger_type for Drive/Calendar, expiring watch channels with renewal, service_config_from_create_response skips update+get, get() reconstructs data from stored service_config (no external "get channel" API).