Developer Portal
How-to guides

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 using one_per_client).
      • webhooksAuthenticationHeader: optional auth headers to send to the webhook.
      • webhooksURLMode: WebhookUrlMode:
        • one_per_client
        • one_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 for one_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 /apps
      • POST /apps/:client_id/subscribe
      • DELETE /apps/:client_id/unsubscribe
      • DELETE /apps/:client_id/unsubscribe/:id (Zapier / per-subscription use, excluded from Swagger)

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 UserApp record per app, containing many subscriptions.
  • **one_per_subscription**
    • Webhook URL lives on the UserApp (UserApp.webhookURL).
    • A user gets a new UserApp record for each subscription (effectively one subscription per UserApp).

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.OnePerClient
  • Client.webhooksURL is the single webhook endpoint for all users and all subscriptions of this App.
  • UserApp.webhookURL is typically unused.

How subscription creation works

  • In UserAppService.subscribe:
    • When webhooksURLMode !== WebhookUrlMode.OnePerSubscription, the service calls:
      • userAppRepository.upsertUserApp(payload)
    • This upserts a single UserApp per (user_id, client_id) pair.
    • The webhook target is always taken from **Client.webhooksURL**, not from the payload.

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 webhooksAuthenticationHeader or webhooksURL in one place.
  • Fewer records:
    • One UserApp document per (user, app) instead of many per subscription.

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_filters live on UserApp, not on Client.
  • In this mode you typically have one UserApp per user+client, but that UserApp can still contain:
    • A set of subscriptions (events/triggers).
    • A set of subscription_filters that are applied before sending to the single Client.webhooksURL.
  • 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 webhookURL in the payload for this mode; the platform uses Client.webhooksURL.

    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 UserApp for that user and triggers logout via Oauth2Service.logoutFromApp.
  • List user’s Apps

    • GET /apps
    • Returns App[]; if the caller is not owner_id, private fields like webhooksURL, clientSecret and webhooksAuthenticationHeader are 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 UserApp row, and:
    • UserApp.webhookURL (or subscription_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) (not upsertUserApp).
  • 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 Request and does not update the existing document.
    • Repeating POST /apps/:client_id/subscribe (or POST /apps/subscribe for 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 and DELETE /apps/:client_id/unsubscribe/:id on the old id.

Validation rules (validateSubscribeOneWebhookUrlPerSubscription)

  1. One logical subscription per successful POST (one new UserApp row)
  • 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.
  • subscriptions is 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 on PUT .../subscriptions/:id.
  1. Special case: Trigger Action integration
  • For client.clientId === 'cv-trigger-action-integration':
    • payload.subscription_actions must be non-empty.
    • webhookURL is 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.
  1. All other Apps using one_per_subscription
  • payload.webhookURL is required.
  • payload.subscription_actions must NOT be provided:
    • If present, a BadRequestException is thrown:
      • “Subscription Actions field is not allowed for this app”.

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.
  • 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 UserApp per subscription; heavy users may create many records.
  • More client-side state:
    • You must store and manage the id of each subscription (UserApp._id) if you want to delete it later.
  • Stricter payload requirements:
    • One new subscription row per POST (no upsert); respect duplicate-identity rules.
    • Must respect the webhookURL vs subscription_actions rules.

Filters in one_per_subscription Apps

  • Each subscription is its own UserApp row, and each row can have its own subscription_filters.
  • Typical usage:
    • One UserApp per 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’s webhookURL (or actions).
  • 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 of SubscriptionEventOrTrigger values for this row (one or more).
      • webhookURL: required.
      • Optional subscription_filters, webhookAuthenticationHeader, etc.

    Example Request (webhookURL required):

    {
      "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’s clientId.
      • id: the UserApp._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 (UserApp records) for that user+client.
  • List user’s Apps

    • GET /apps
    • Returns logical App objects, 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 /apps endpoints 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 PAT cv:write on an owned app—see product docs).
  • Shared endpoints:
    • GET /apps
    • POST /apps/:client_id/subscribe
    • DELETE /apps/:client_id/unsubscribe

Differences

  • **one_per_client**
    • Use:
      • POST /apps/:client_id/subscribe
      • DELETE /apps/:client_id/unsubscribe
    • Do not depend on:
      • UserApp.webhookURL in the payload.
    • Webhook target lives at:
      • Client.webhooksURL.
  • **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/:id to update an existing subscription in place (subscriber only).
      • GET /apps/subscriptions to list subscriptions you created for apps you own.
      • DELETE /apps/:client_id/unsubscribe/:id for per-subscription removal.
      • DELETE /apps/:client_id/unsubscribe to remove all subscriptions and log out.
    • Webhook target / action lives at:
      • UserApp.webhookURL or UserApp.subscription_actions (Trigger Action).

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.subscribe calls:
    • userAppRepository.upsertUserApp(payload) when webhooksURLMode !== OnePerSubscription.
  • This means:
    • There is at most one UserApp per (user_id, client_id).
    • Calling POST /apps/:client_id/subscribe multiple times for the same user+App:
      • Creates the UserApp on first call.
      • Updates/overwrites it on subsequent calls (subscriptions, filters, headers, etc.).
  • From a consumer’s point of view:
    • POST /apps/:client_id/subscribe is 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_client App:
    • Build the new desired payload (full picture).
    • Call POST /apps/:client_id/subscribe again.
    • The previous UserApp row 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.subscribe calls:
    • userAppRepository.create(payload) (after validation)—never upsertUserApp for this mode.
  • This means:
    • Each call inserts a new UserApp row 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 Request and does not create or update a row.
    • POST .../subscribe is not idempotent for identical payloads: same identity → 400, not “update existing.”
    • A changed payload (e.g. different subscriptions only, 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 id returned in SubscribedUser for DELETE .../unsubscribe/:id and for PUT /apps/:client_id/subscriptions/:id.

There are two main patterns for “updating” in this mode:

  1. Update in place (preferred when you already have the subscription id)

    • Call PUT /apps/:client_id/subscriptions/:id with the allowed fields (SubscribeUser-shaped payload subset handled by UpdateSubscriptionPayload).
    • Authorization: only the subscriber (user_app.user_id) may update that id; app ownership alone is not sufficient to edit another user’s subscription.
  2. Create new, then delete old

    • For migrations or when you do not store the old id:
      1. POST /apps/:client_id/subscribe with the new configuration (must not exactly match an existing row’s identity, or you get 400).
      2. Store the new subscription id.
      3. DELETE /apps/:client_id/unsubscribe/:id for the old subscription id.
    • Useful when mirroring external “new Zap, retire old Zap” flows.

Guidance

  • For one_per_subscription Apps:
    • 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.id for updates and deletes.
    • Prefer PUT .../subscriptions/:id to 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); use PUT /apps/:client_id/subscriptions/:id or create+delete to change behavior.

Guidance for AI Agents

When writing code or generating API calls:

  • Always read Client.webhooksURLMode before deciding:
    • Whether to include webhookURL or subscription_actions in POST /apps/:client_id/subscribe.
    • Whether to call DELETE /apps/:client_id/unsubscribe vs DELETE /apps/:client_id/unsubscribe/:id.
  • For **one_per_client**:
    • Do not invent per-subscription URLs; rely on Client.webhooksURL.
    • Treat UserApp as a single per-user configuration object.
  • For **one_per_subscription**:
    • Treat subscriptions as 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/:id to update in place, or
      • DELETE /apps/:client_id/unsubscribe/:id to remove.
    • Do not rely on repeated POST .../subscribe to update an existing subscription (duplicates → 400; partial changes → extra rows).
    • For cv-trigger-action-integration:
      • Prefer subscription_actions and flags like run_on_short_message instead of webhookURL.

Following these rules will keep generated integrations consistent with the current implementation in:

  • src/oauth2/schema/client.schema.ts
  • src/user-app/user-app.schema.ts
  • src/user-app/user-app.service.ts
  • src/user-app/user-app.controller.ts
How Apps and Webhook Modes Work | Carbon Voice Developer Portal