How Apps and Webhook Modes Work
This document explains how Apps work in the CV API, with a focus on the Client.webhooksURLMode configuration:
**one_per_client**: one shared webhook URL per App (client)**one_per_subscription**: one webhook URL (or action) per individual subscription
It is written for both humans and AI agents that need to reason about how to configure and use Apps correctly.
Core Data Models
**Client** (src/oauth2/schema/client.schema.ts)- Represents an App.
- Key fields:
clientId: unique identifier for the App.webhooksURL: webhook endpoint owned by the App (when usingone_per_client).webhooksAuthenticationHeader: optional auth headers to send to the webhook.webhooksURLMode: WebhookUrlMode:one_per_clientone_per_subscription
owner_id,is_auto_register, etc.
**UserApp** (src/user-app/user-app.schema.ts)- Represents the relationship User ↔ App, including subscriptions and webhook configuration.
- Stored in collection
oauth2_user_clients. - Key fields:
user_id: the CV user.client_id: the App (Client.clientId).subscriptions: SubscriptionEventOrTrigger[]: which events/triggers are enabled.webhookURL?: string:null/unused forone_per_client.- Required (or replaced by actions) for
one_per_subscription.
webhookAuthenticationHeader?: Record<string, any>: per-subscription auth (used mainly with Zapier-style flows).subscription_actions?: SubscriptionAction[]: actions for Trigger Action–style integrations.subscription_filters?: SubscriptionFilter[]: optional filters.run_on_short_message: boolean: Trigger Action–specific flag.scheduler_job_metadata,rrule: scheduling metadata for time-based triggers.
**UserAppService** (src/user-app/user-app.service.ts)- Encapsulates business logic for subscribing/unsubscribing users and listing their apps.
- Applies different rules depending on
client.webhooksURLMode.
**UserAppController** (src/user-app/user-app.controller.ts)- HTTP API under
@Controller('apps')with@TokenAuth():GET /appsPOST /apps/:client_id/subscribeDELETE /apps/:client_id/unsubscribeDELETE /apps/:client_id/unsubscribe/:id(Zapier / per-subscription use, excluded from Swagger)
- HTTP API under
Webhook Delivery Modes (webhooksURLMode)
webhooksURLMode controls where the webhook URL lives and how many subscriptions map to a single UserApp record.
**one_per_client**- Webhook URL lives on the
Client(Client.webhooksURL). - A user has a single
UserApprecord per app, containing many subscriptions.
- Webhook URL lives on the
**one_per_subscription**- Webhook URL lives on the
UserApp(UserApp.webhookURL). - A user gets a new
UserApprecord for each subscription (effectively one subscription perUserApp).
- Webhook URL lives on the
The same subscription endpoints are used for both modes, but the data model and responsibilities change significantly.
1. one_per_client — One Webhook URL per App
Definition (from Client):
Client.webhooksURLMode === WebhookUrlMode.OnePerClientClient.webhooksURLis the single webhook endpoint for all users and all subscriptions of this App.UserApp.webhookURLis typically unused.
How subscription creation works
- In
UserAppService.subscribe:- When
webhooksURLMode !== WebhookUrlMode.OnePerSubscription, the service calls:userAppRepository.upsertUserApp(payload)
- This upserts a single
UserAppper(user_id, client_id)pair. - The webhook target is always taken from
**Client.webhooksURL**, not from the payload.
- When
Intended usage
Use one_per_client when:
- Your integration exposes one stable webhook endpoint that:
- Receives events for all of your users of that App, or
- Uses its own internal routing / authentication to separate tenants.
- You do not need a different URL per subscription or per Zap-like entity.
- You prefer to manage:
- Webhook URL configuration,
- Authentication headers,
- Rotation of secrets centrally at the App (Client) level.
Pros
- Simple configuration:
- One webhook URL to configure per App.
- No need to persist different URLs per user/subscription.
- Centralized secret management:
- Rotate
webhooksAuthenticationHeaderorwebhooksURLin one place.
- Rotate
- Fewer records:
- One
UserAppdocument per(user, app)instead of many per subscription.
- One
Cons
- Less granularity:
- Cannot point different subscriptions to different URLs.
- Harder to “turn off” a single subscription endpoint; you manage that purely on your side.
- Not a great fit for Zapier-like platforms:
- Those typically allocate one URL per Zap / recipe / subscription.
Filters in one_per_client Apps
subscription_filterslive onUserApp, not onClient.- In this mode you typically have one
UserAppper user+client, but thatUserAppcan still contain:- A set of
subscriptions(events/triggers). - A set of
subscription_filtersthat are applied before sending to the singleClient.webhooksURL.
- A set of
- This means:
- You can scope which events for a given user are sent to the shared App webhook.
- All events that pass the filters still go to the same URL; the filters are about when to send, not where.
Required CV API endpoints for one_per_client Apps
-
Subscribe user into App
POST /apps/:client_id/subscribe- Input payload (simplified):
subscriptions: SubscriptionEventOrTrigger[]- (Plus optional filters / metadata as supported by
SubscribeUser)
- Important:
- Do not send
webhookURLin the payload for this mode; the platform usesClient.webhooksURL.
- Do not send
Example Request (webhook URL is NOT required):
{ "subscriptions": ["message.finished", "schedule"], "subscription_filters": [ { "key": "channel_id", "value": "abc123", "operator": "eq" } ] }Example Response (
SubscribedUser):{ "id": "66f0c8b2c0b1e00012345678", "client_id": "your-client-id", "user_id": "66f0c8b2c0b1e00099999999", "subscriptions": ["message.finished", "schedule"], "webhookURL": null, "subscription_filters": [ { "key": "channel_id", "value": "abc123", "operator": "eq" } ] } -
Unsubscribe user from App
DELETE /apps/:client_id/unsubscribe- Removes the
UserAppfor that user and triggers logout viaOauth2Service.logoutFromApp.
-
List user’s Apps
GET /apps- Returns
App[]; if the caller is notowner_id, private fields likewebhooksURL,clientSecretandwebhooksAuthenticationHeaderare stripped.
You do not normally use DELETE /apps/:client_id/unsubscribe/:id in this mode, because there is a single UserApp per (user, client) and subscriptions are not modeled as separate webhook URLs.
2. one_per_subscription — One Webhook URL per Subscription
Definition (from Client):
Client.webhooksURLMode === WebhookUrlMode.OnePerSubscription- Each subscription is backed by its own
UserApprow, and:UserApp.webhookURL(orsubscription_actions) defines where/how to deliver events.
How subscription creation works
- In
UserAppService.subscribe:- When
client.webhooksURLMode === WebhookUrlMode.OnePerSubscription:- The service calls
validateSubscribeOneWebhookUrlPerSubscription({ payload, client }). - Then it inserts via
userAppRepository.create(payload)(notupsertUserApp).
- The service calls
- When
- Not upsert, not idempotent POST (duplicate identity):
- Each successful subscribe creates a new row when the payload’s identity is new for
(user_id, client_id). - If a row already exists with the same webhook URL, subscription events, filters, and actions (compared order-insensitively), the API returns
400 Bad Requestand does not update the existing document. - Repeating
POST /apps/:client_id/subscribe(orPOST /apps/subscribefor the default app) with the exact same identity is therefore not a no-op retry: treat it as an error unless you intend to detect duplicates client-side. - To change an existing subscription without creating a parallel row, use
PUT /apps/:client_id/subscriptions/:id(subscriber-scoped), or create a new subscription andDELETE /apps/:client_id/unsubscribe/:idon the old id.
- Each successful subscribe creates a new row when the payload’s identity is new for
Validation rules (validateSubscribeOneWebhookUrlPerSubscription)
- One logical subscription per successful POST (one new
UserApprow)
- Each subscribe inserts a row when the identity is new. Duplicate identity (same webhook URL, set of subscription events, filters, and actions) returns 400 from the repository layer.
subscriptionsis an array of event types / triggers for that row. It may contain multiple values; the full set participates in duplicate detection (order-insensitive), same as onPUT .../subscriptions/:id.
- Special case: Trigger Action integration
- For
client.clientId === 'cv-trigger-action-integration':payload.subscription_actionsmust be non-empty.webhookURLis not required for this special client.- This mode is used to store subscription actions (and related flags like
run_on_short_message) instead of a plain webhook URL.
- All other Apps using
one_per_subscription
payload.webhookURLis required.payload.subscription_actionsmust NOT be provided:- If present, a
BadRequestExceptionis thrown:- “Subscription Actions field is not allowed for this app”.
- If present, a
Intended usage
Use one_per_subscription when:
- You need one URL or action per user subscription, not per App:
- Zapier-style integrations (one URL per Zap).
- Systems where each recipe / rule / automation has a distinct HTTP endpoint or configuration.
- You need the ability to:
- Add or remove individual subscriptions independently.
- Attach different filters or actions to different subscriptions.
Pros
- Maximum granularity:
- Each subscription can have its own:
webhookURL,- headers,
- filters,
- actions.
- Each subscription can have its own:
- Per-subscription lifecycle:
- You can subscribe/unsubscribe one Zap or rule at a time without affecting others.
- Better mapping to external automation platforms:
- Matches the way Zapier / similar tools think about webhooks.
Cons
- More records:
- One
UserAppper subscription; heavy users may create many records.
- One
- More client-side state:
- You must store and manage the
idof each subscription (UserApp._id) if you want to delete it later.
- You must store and manage the
- Stricter payload requirements:
- One new subscription row per POST (no upsert); respect duplicate-identity rules.
- Must respect the
webhookURLvssubscription_actionsrules.
Filters in one_per_subscription Apps
- Each subscription is its own
UserApprow, and each row can have its ownsubscription_filters. - Typical usage:
- One
UserAppper Zap / rule:subscriptions: which event types that Zap receives (often one; can be several).subscription_filters: conditions that must match before the event is sent to that subscription’swebhookURL(or actions).
- One
- Practical effect:
- Filters are per-subscription instead of per-user+client.
- Different Zaps for the same user+client can listen to the same event type but with different filters and different URLs.
Required CV API endpoints for one_per_subscription Apps
-
Create a subscription
POST /apps/:client_id/subscribe- Input payload (simplified, for “normal” external Apps):
subscriptions: array ofSubscriptionEventOrTriggervalues for this row (one or more).webhookURL: required.- Optional
subscription_filters,webhookAuthenticationHeader, etc.
Example Request (
webhookURLrequired):{ "subscriptions": ["message.finished", "message.posted.to.channel"], "webhookURL": "https://hooks.zapier.com/hooks/catch/123/abc", "subscription_filters": [ { "key": "author_id", "value": "user_123", "operator": "eq" } ] }Example Response (
SubscribedUser):{ "id": "66f0c8b2c0b1e00022222222", "client_id": "zapier-client-id", "user_id": "66f0c8b2c0b1e00099999999", "subscriptions": [ "message.finished", "message.posted.to.message.posted.to.channel" ], "webhookURL": "https://hooks.zapier.com/hooks/catch/123/abc", "subscription_filters": [ { "key": "author_id", "value": "user_123", "operator": "eq" } ] } -
Unsubscribe an individual subscription (Zapier-style)
DELETE /apps/:client_id/unsubscribe/:id- Parameters:
client_id: the App’sclientId.id: theUserApp._id(subscription identifier).
Example Call:
DELETE /apps/zapier-client-id/unsubscribe/66f0c8b2c0b1e00022222222 -
Unsubscribe the user from all subscriptions for an App
DELETE /apps/:client_id/unsubscribe- Removes all subscriptions (
UserApprecords) for that user+client.
-
List user’s Apps
GET /apps- Returns logical
Appobjects, not individual subscriptions.
Choosing the Right Mode
When to choose one_per_client
- You have:
- A stateless, multi-tenant webhook endpoint that can handle events for all users.
- Internal routing logic based on identifiers inside the payload.
- You want:
- Simpler configuration for integrators.
- Centralized management of webhook URL and auth.
- You do not need:
- Per-subscription URLs.
- Fine-grained unsubscribe logic at the “Zap” level.
Short rule of thumb:
If a single webhook URL per App is enough and the external system can route internally, use **one_per_client**.
When to choose one_per_subscription
- You integrate with:
- Platforms that create a distinct webhook URL per automation (e.g. Zapier).
- Systems where each rule / workflow has its own endpoint or independent lifecycle.
- You need:
- Per-subscription unsubscribe and configuration.
- Different filters / headers / actions for each subscription.
Short rule of thumb:
If each subscription (Zap, rule, workflow) needs its own endpoint or configuration, use **one_per_subscription**.
Endpoint Summary by Mode
Common requirements (both modes)
- All
/appsendpoints are protected with@TokenAuth()and require:- A valid user token (
@CurrentUser). - For routes with
:client_id, a resolved app consistent with auth (OAuth token for that client, allowlisted portal token + owned target, or PATcv:writeon an owned app—see product docs).
- A valid user token (
- Shared endpoints:
GET /appsPOST /apps/:client_id/subscribeDELETE /apps/:client_id/unsubscribe
Differences
**one_per_client**- Use:
POST /apps/:client_id/subscribeDELETE /apps/:client_id/unsubscribe
- Do not depend on:
UserApp.webhookURLin the payload.
- Webhook target lives at:
Client.webhooksURL.
- Use:
**one_per_subscription**- Use:
POST /apps/subscribe(optional): same body as subscribe, but the API provisions or reuses the user’s default portal app (one_per_subscription+is_default_subscription_client).POST /apps/:client_id/subscribe(one subscription at a time).PUT /apps/:client_id/subscriptions/:idto update an existing subscription in place (subscriber only).GET /apps/subscriptionsto list subscriptions you created for apps you own.DELETE /apps/:client_id/unsubscribe/:idfor per-subscription removal.DELETE /apps/:client_id/unsubscribeto remove all subscriptions and log out.
- Webhook target / action lives at:
UserApp.webhookURLorUserApp.subscription_actions(Trigger Action).
- Use:
Updating vs Upserting (How Changes Behave)
Understanding how updates behave is critical, especially for AI agents that generate integration code.
one_per_client: subscribe is effectively an “upsert”
For Apps using one_per_client:
UserAppService.subscribecalls:userAppRepository.upsertUserApp(payload)whenwebhooksURLMode !== OnePerSubscription.
- This means:
- There is at most one
UserAppper(user_id, client_id). - Calling
POST /apps/:client_id/subscribemultiple times for the same user+App:- Creates the
UserAppon first call. - Updates/overwrites it on subsequent calls (subscriptions, filters, headers, etc.).
- Creates the
- There is at most one
- From a consumer’s point of view:
POST /apps/:client_id/subscribeis idempotent for a given user+App:- You can “upsert” the configuration by calling it again with the full desired state.
- Think of it as “set my subscriptions/filters for this App to exactly this payload”.
Guidance
- To change subscriptions / filters / headers for a
one_per_clientApp:- Build the new desired payload (full picture).
- Call
POST /apps/:client_id/subscribeagain. - The previous
UserApprow will be updated in place.
one_per_subscription: subscribe inserts; duplicates are rejected (not upsert, not idempotent POST)
For Apps using one_per_subscription:
UserAppService.subscribecalls:userAppRepository.create(payload)(after validation)—neverupsertUserAppfor this mode.
- This means:
- Each call inserts a new
UserApprow when the subscription is not a duplicate. - A duplicate is defined as the same
webhookURL, same subscription events, same filters, and same actions for that(user_id, client_id), compared in an order-insensitive way (event list, filter list, and filter array values). - If the API detects a duplicate, it responds with
400 Bad Requestand does not create or update a row. POST .../subscribeis not idempotent for identical payloads: same identity → 400, not “update existing.”- A changed payload (e.g. different
subscriptionsonly, same webhook) is treated as non-duplicate and inserts another row—so do not use repeated POST as a substitute for PUT when you mean “update this subscription.” - Persist the
idreturned inSubscribedUserforDELETE .../unsubscribe/:idand forPUT /apps/:client_id/subscriptions/:id.
- Each call inserts a new
There are two main patterns for “updating” in this mode:
-
Update in place (preferred when you already have the subscription id)
- Call
PUT /apps/:client_id/subscriptions/:idwith the allowed fields (SubscribeUser-shaped payload subset handled byUpdateSubscriptionPayload). - Authorization: only the subscriber (
user_app.user_id) may update thatid; app ownership alone is not sufficient to edit another user’s subscription.
- Call
-
Create new, then delete old
- For migrations or when you do not store the old id:
POST /apps/:client_id/subscribewith the new configuration (must not exactly match an existing row’s identity, or you get 400).- Store the new subscription
id. DELETE /apps/:client_id/unsubscribe/:idfor the old subscriptionid.
- Useful when mirroring external “new Zap, retire old Zap” flows.
- For migrations or when you do not store the old id:
Guidance
- For
one_per_subscriptionApps:- Do not document or assume “POST subscribe = upsert” or “repeat POST = safe retry”: duplicate identity returns 400; near-duplicate creates another row.
- Treat each successful non-400 subscribe as creating a new subscription; persist
SubscribedUser.idfor updates and deletes. - Prefer
PUT .../subscriptions/:idto change webhook URL, events, filters, or actions without growing row count.
In short:
one_per_client→ Think in terms of one mutable config per user+App, updated via upsert.one_per_subscription→ Think in terms of many subscriptions, each with its own ID; POST subscribe = insert (duplicate identity → 400); usePUT /apps/:client_id/subscriptions/:idor create+delete to change behavior.
Guidance for AI Agents
When writing code or generating API calls:
- Always read
Client.webhooksURLModebefore deciding:- Whether to include
webhookURLorsubscription_actionsinPOST /apps/:client_id/subscribe. - Whether to call
DELETE /apps/:client_id/unsubscribevsDELETE /apps/:client_id/unsubscribe/:id.
- Whether to include
- For
**one_per_client**:- Do not invent per-subscription URLs; rely on
Client.webhooksURL. - Treat
UserAppas a single per-user configuration object.
- Do not invent per-subscription URLs; rely on
- For
**one_per_subscription**:- Treat
subscriptionsas the list of event types for that subscription row (multiple allowed); duplicate full identity on POST returns 400. - Persist the returned subscription identifier (
SubscribedUser.id/UserApp._id) so you can call:PUT /apps/:client_id/subscriptions/:idto update in place, orDELETE /apps/:client_id/unsubscribe/:idto remove.
- Do not rely on repeated
POST .../subscribeto update an existing subscription (duplicates → 400; partial changes → extra rows). - For
cv-trigger-action-integration:- Prefer
subscription_actionsand flags likerun_on_short_messageinstead ofwebhookURL.
- Prefer
- Treat
Following these rules will keep generated integrations consistent with the current implementation in:
src/oauth2/schema/client.schema.tssrc/user-app/user-app.schema.tssrc/user-app/user-app.service.tssrc/user-app/user-app.controller.ts