---
title: Channel apps
slug: data-platform/connect-platform/ocpcoreconcepts-channel
docTags: 
createdAt: 2026-08-05T20:06:35.878Z
---

After a user installs a channel app, they can add it to any Optimizely Data Platform (ODP) campaign touchpoint, shown below:

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/BnSJvQqoIQUt3IpHulrGm_e73a659-image.png)

The typical channel interface flow is as follows:

:::hint{type="info"}
**Note**
For channel app implementation details, see the [Connect Platform App SDK documentation](docId:9MPJ0gwsoXoAubpkwFgyD).
:::

1. Check if the channel is [ready](docId:9MPJ0gwsoXoAubpkwFgyD) to use. For example, ensure the app user has configured their credentials before you attempt to send touchpoints through the channel.
2. (Optional) Dynamically determine the [target](docId:9MPJ0gwsoXoAubpkwFgyD) (customer identifier fields such as email address) for a piece of content. If this information is statically known ahead of time, provide it in the `app.yml` file instead.
3. [Validate](docId:9MPJ0gwsoXoAubpkwFgyD) the content settings and template. This may happen at any time and is not strictly associated with the lifecycle of the content itself. You can provide error messages and toast notifications to the user when they attempt to launch the campaign.
4. [Publish](docId:9MPJ0gwsoXoAubpkwFgyD) the content template. This happens when the campaign content changes. You should pre-process campaign content if necessary (for example, if the system you are delivering to uses a different templating system) and store it for use during the campaign deliveries.
5. (Optional) [Prepare](docId:9MPJ0gwsoXoAubpkwFgyD) for a campaign run, allowing any setup required by a third party to deliver a set of content. If you do not need this step, you can leave the method unimplemented and turned off in the `app.yml` file with `channel.options.prepare`.
6. [Deliver](docId:9MPJ0gwsoXoAubpkwFgyD) content to batches of recipients with substitutions. During a campaign run, the target identifier and substitutions are provided in batches of 100 and delivered to the user through the channel.

In addition to implementing the channel interface, a channel app must also have:

1. The category `channel` specified in the `app.yml` file.
2. The `channel` section of the `app.yml` file.
3. The `forms/content-settings.yml` and `forms/content-template.yml` files. These forms display to the user when creating content for a campaign to send to this channel. Provide the data from these forms to the `validate` and `publish` channel methods.

## Form validation

Channel apps are powered by their forms, which contain user input. Apps have two mechanisms to validate user input before a customer is able to go live with a campaign containing the channel app.

### Inline validation

In the following `forms/content-settings.yml` file example, the URL must start with `http://` or `https://`. If the user enters anything else, the form displays the following error message before a user can go live: "Must be an http or https url." With this example, the app assumes that when it accesses the form data, the URL is a string value that starts with `http://` or `https://`.

```yaml
sections:
  - key: request
    label: Request
    elements:
      - key: url
        type: text
        label: URL
        help: HTTP URL to deliver to
        required: true
        validations:
          - regex: "^https?:\\/\\/"
            message: Must be an http or https url
```

:::hint{type="warning"}
**Caution**
Be careful about making validation changes with new versions of your app. Updates to your forms must be compatible with older campaigns to ensure a smooth user experience. Additionally, you may not enforce form validation added to a newer version of your app on a campaign that was set live with a previous version of your app. You are responsible for migrating the form data at runtime.
:::

### Runtime validation

You should handle more complicated validation at runtime with the `validate` method, which is called before a user can go live with their campaign. The app can return errors and toast notifications for each form value that is invalid. If the app returns any errors, `publish` is not called, and the campaign cannot go live.

:::hint{type="info"}
**Note**
You do not need any code to enforce validations.
:::

## Example channel app

The following files are an example of implementing an API-based channel app. First, the `app.yml` file:

```yaml
meta:
  app_id: api_channel
  display_name: API Channel
  version: 1.0.16
  vendor: optimizely
  summary: Combine the power of ODP campaigns with any API to deliver content or updates through other integrations.
  support_url: https://apps.optimizely.com/api-channel
  contact_email: support@optimizely.com
  categories:
    - Channel
  availability:
    - us
runtime: node22
functions:
  list_identifiers:
    entry_point: ListIdentifiers
    description: Lists messaging identifiers for the channel form
channel:
  type: api
  targeting: dynamic
  options:
    prepare: false
  metrics:
    delivery:
      - sent
    reachability:
      - soft_bounce
```

The `channel/metrics` section defines event types that are sent to ODP by the app. ODP uses this configuration to determine which events to expect from the app and how to process them. For more information about possible event types, see [Campaign and channel events](docId\:j3MCJxVuVsTKtm8-hXZfW).

Next, the `forms/content-settings.yml` file:

```yaml
sections:
  - key: target
    label: Targeting
    elements:
      - key: identifier
        label: Target Identifier
        type: select
        help: Select an ODP Identifier for targeting
        required: true
        dataSource:
          type: app
          function: list_identifiers
  - key: headers
    label: Headers
    elements:
      - key: h1_key
        label: Header Name 1
        help: Header name (for example, Content-Type)
        type: text
        hint: Content-Type
        defaultValue: Content-Type
      - key: h1_value
        label: Header Value 1
        help: Header value (for example, application/json)
        type: text
        hint: application/json
        defaultValue: application/json
      - key: h2_key
        label: Header Name 2
        help: Header name (for example, Content-Type)
        type: text
        hint: Content-Type
      - key: h2_value
        label: Header Value 2
        help: Header value (for example, application/json)
        type: text
        hint: Content-type
```

Finally, the `forms/content-template.yml` file:

```yaml
sections:
  - key: request
    label: Request
    elements:
      - key: method
        label: API Method
        help: API Call Method (for example, POST or GET)
        type: select
        options:
          - value: GET
            text: GET
          - value: POST
            text: POST
          - value: PUT
            text: PUT
          - value: PATCH
            text: PATCH
          - value: DELETE
            text: DELETE
        hint: Content-Type
        defaultValue: Content-Type
      - key: url
        type: text
        label: URL
        help: HTTP URL to deliver to
        required: true
        validations:
          - regex: "^https?:\\/\\/"
            message: Must be an http or https url
      - key: body
        type: text
        label: Body Content
        help: Post Body
        multiline: true
        defaultValue: "{}"
        visible:
          operation: any
          comparators:
            - key: method
              equals: 'POST'
            - key: method
              equals: 'PUT'
            - key: method
              equals: 'PATCH'
```

The image below is an example of how the above configuration displays in an ODP campaign:

![](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/lKaVnrIGqgnivYRmRAy9v_d456c72-image.png)

Number, type, and names of the fields in both forms depends on what your third-party service requires to process the requests. The app's channel interface must support preview, test sends, and delivery. The following is a simplified example of the `src/channel/Channel.ts` file:

```typescript
import * as App from '@zaiusinc/app-sdk';
import {CampaignContent, CampaignDelivery, CampaignTracking, ChannelContentResult, ChannelDeliverOptions, ChannelDeliverResult, ChannelPreviewResult, ChannelPublishOptions, ChannelTargetResult, ChannelValidateOptions, KVHash, storage} from '@zaiusinc/app-sdk';
import {ContentSettingsFormData} from '../lib/ContentSettingsFormData';
import {ContentTemplateFormData} from '../lib/ContentTemplateFormData';
import {previewTemplate} from '../lib/previewTemplate';

/**
 * Defines the interface of a channel. The typical channel flow in a campaign run is as follows:
 * 1. Check if the channel is `ready` to use
 * 2. Dynamically determine the `target` (may be provided statically in `app.yml` file instead)
 * 3. `publish` the content template (in the future, this will instead happen when the campaign is modified)
 * 4. `prepare` for the run (if the method is implemented)
 * 5. `deliver` the content to batches of recipients with substitutions
 *
 * Outside of the campaign run flow, a channel must also be able to `preview` a given piece of content for a batch
 * of recipients.
 */
export class Channel extends App.Channel {
  /**
   * Checks if the channel is ready to use. This should ensure that any required credentials and/or other configuration
   * exist and are valid. Utilize reasonable caching to prevent excessive requests to external resources.
   * @async
   * @returns true if the channel is ready to use
   */
  public async ready(): Promise<boolean> {
    return true;
  }

  /**
   * Dynamically determines campaign targeting requirements. It should also perform any necessary validations on the
   * input data. If targeting is always known ahead of time, specify this statically in `channel.targeting`
   * in the `app.yml` file. If targeting is based on selections made in the content settings form, you must implement this method
   * and the value in the `app.yml` file must be set to `dynamic`.
   * @async
   * @param contentSettings data from the content settings form
   * @returns result of the operation
   */
  public async target(contentSettings: ContentSettingsFormData): Promise<ChannelTargetResult> {
    const result = new ChannelTargetResult();
    result.addTargeting({identifier: contentSettings.target.identifier});
    return result;
  }

  /**
   * Validates the given content. This should ensure that the content is suitable for use in the current mode, as
   * specified in the options (see {@link ChannelValidateOptions.mode}). If specific fields are missing or invalid,
   * provide appropriate error messages using {@link ChannelContentResult.addError}. You should provide any errors that are
   * not linked to a specific field using {@link ChannelContentResult.addToast}. If no errors of
   * either type are returned, the validation is considered successful, and the operation is allowed to proceed.
   * @async
   * @param content the content with translated templates
   * @param options additional options
   * @returns result of the operation
   */
  public async validate(
    content: CampaignContent, options: ChannelValidateOptions
  ): Promise<ChannelContentResult> {
    const result = new ChannelContentResult();
    this.validateSettings(content.settings as ContentSettingsFormData, result);
    await this.validateTemplate(content.template as ContentTemplateFormData, result);
    return result;
  }

  /**
   * Publishes the given content. This is the place to perform any necessary transformations between the given template
   * format and the external system's format. It can be assumed that {@link Channel.validate} has already been called,
   * but additional errors may still be detected in this phase and returned in the same way as during validation.
   * <p>
   * If you must store the content in an external system, this is also the time to do that. If the content must instead
   * be known in `prepare` or `deliver`, place it in the document store for future use.
   * <p>
   * You may call this method multiple times with the same content key. But for a given content key, it is always
   * called with the same content and options. As such, when successful, treat it as an idempotent
   * operation at the content key level. So if the given key has already been processed and successfully stored, there
   * is no need to process and store it again.
   * @async
   * @param contentKey unique key for the content
   * @param content the content with translated templates
   * @param options additional options
   * @returns result of the operation
   */
  public async publish(
    contentKey: string, content: CampaignContent, options: ChannelPublishOptions
  ): Promise<ChannelContentResult> {
    const result = new ChannelContentResult();
    if (
      this.validateSettings(content.settings as ContentSettingsFormData, result)
      && await this.validateTemplate(content.template as ContentTemplateFormData, result)
    ) {
      await storage.kvStore.put(`content:${contentKey}`, content as CampaignContent & KVHash);
    }
    return result;
  }

  /**
   * Prepares for a campaign run. You can use this to set up an external entity for use in `deliver` (or perform any
   * other processing that should only be performed once per run). If this step is unnecessary, simply do not implement
   * the method.
   * <p>
   * If implemented, this method is called exactly once per content key involved in a campaign run. If any one of
   * these fails, the campaign run will fail.
   * @async
   * @param contentKey unique key of the content
   * @param tracking campaign tracking parameters
   * @param options additional options
   * @returns result of the operation
   */
  // public async prepare?(
  //   contentKey: string, tracking: CampaignTracking, options: ChannelPrepareOptions
  // ): Promise<ChannelPrepareResult>;

  /**
   * Delivers a batch of messages. You may call this method many times for the same content key, tracking parameters,
   * and options, each with a unique batch of recipients and substitutions. It is assumed that a batch either succeeds
   * or fails as a whole. There is no partial delivery handling.
   * <p>
   * If a batch fails, you may retry it (as controlled by the return value). When that happens, the subsequent call(s)
   * for that batch will be given the previous result for reference to enable proper recovery logic.
   * <p>
   * Once a batch succeeds, it will never be given again.
   * @async
   * @param contentKey unique key of the content
   * @param tracking campaign tracking parameters
   * @param options additional options
   * @param batch of recipients and substitutions
   * @param previousResult previous result of the operation, if this is a retry
   * @returns result of the operation
   */
  public async deliver(
    contentKey: string,
    tracking: CampaignTracking,
    options: ChannelDeliverOptions,
    batch: CampaignDelivery[],
    previousResult?: ChannelDeliverResult
  ): Promise<ChannelDeliverResult> {
    // TODO: Deliver the batch of content here. See the example API Channel app for details.
  }

  /**
   * Renders a batch of messages into HTML previews. Each preview must be a full HTML page containing a user-friendly
   * representation of the message as it would be delivered.
   * @async
   * @param content the content with translated templates
   * @param batch of recipients and substitutions
   * @returns result of the operation
   */
  public async preview(content: CampaignContent, batch: CampaignDelivery[]): Promise<ChannelPreviewResult> {
    const result = new ChannelPreviewResult();
    result.setPreviews(batch.map((delivery) => {
      const template = content.template as ContentTemplateFormData;
      const {method} = template.request;
      const [url, body] = this.performSubstitutions(
        delivery.substitutions, template.request.url, template.request.body || ''
      );

      const settings = content.settings as ContentSettingsFormData;

      return previewTemplate(this.formatHeaders(settings.headers), `${method} ${url}`, body);
    }));
    return result;
  }

  // ...

}
```

`ContentSettingsFormData` and `ContentTemplateFormData` are TypeScript interfaces that represent the data from the content settings and content template forms, respectively. Connect Platform for ODP fills these interfaces with the data provided by ODP users in campaign forms and passes them to your channel app in callback methods.

```typescript
import {Schema} from '@zaiusinc/app-forms-schema';

export interface ContentSettingsFormData extends Schema.FormData {
  target: {
    identifier: string;
  };
  headers: {
    h1_key: string;
    h1_value: string;
    h2_key: string;
    h2_value: string;
    h3_key: string;
    h3_value: string;
    h4_key: string;
    h4_value: string;
  };
}
```

```typescript
import {Schema} from '@zaiusinc/app-forms-schema';

export interface ContentTemplateFormData extends Schema.FormData {
  request: {
    method: string;
    url: string;
    body: string | null;
  };
}
```

The `previewTemplate` function is a helper function that renders a preview of the content template.

```typescript
/* eslint-disable max-len */
export function previewTemplate(headers: string, url: string, body?: string) {
  return `
  <!doctype html>
  <html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Template</title>
    <meta name="description" content="Template">
    <meta name="author" content="Zaius">
    <style>
      html {
        background: transparent;
      }
      body {
        font-family: sans-serif;
        color: rgba(0,0,0,.9);
        margin: 20px 0;
        display: flex;
        justify-content: center;
        background: transparent;
      }

      * {
        box-sizing: border-box;
      }

      h5 {
        text-transform: uppercase;
        color: rgba(0,0,0,.6);
        font-size: 11px;
      }

      .api {
        min-width: 350px;
        background: white;
        padding: 20px;
        border-radius: 5px;
      }

      .info {
        display: flex;
        align-items: center;
        font-size: 12px;
        background: rgba(20, 172, 228, 0.16);
        color: #14ACE4;
        padding: 10px;
        border-radius: 3px;
      }

      .icon {
        flex-shrink: 0;
        width: 18px;
        height: 18px;
        margin-right: 10px;
      }
      pre {
        background: rgba(0,0,0,.05);
        border-radius: 3px;
        padding: 10px;
        white-space: pre-wrap;
        overflow: auto;
      }
    </style>
  </head>
  <body>
  <div class="api">
    <div class="info">
      <svg class="icon" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="info-circle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-info-circle fa-w-16 fa-9x"><path fill="currentColor" d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z" class=""></path></svg>
      This is only a preview of the API request. No API calls were made.
    </div>
    <h5>Headers</h5>
    <pre class="headers">${headers}</pre>
    <h5>URL</h5>
    <pre class="url">${url}</pre>
    ${body === '' ? '' : `<h5>Body</h5><pre class="body">${body}</pre>`}
  </div>
  </body>
  </html>
`;
}
```
