Dev Guide
Dev GuideUser GuideGitHubNuGetDevCommunitySubmit a ticketLog In
GitHubNuGetDevCommunitySubmit a ticket

Channel apps

In Optimizely Connect Platform (OCP), channels enable you to deliver content from the Activation feature in Optimizely Data Platform (ODP) through any third-party provider.

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

The typical channel interface flow is as follows:

📘

Note

For channel app implementation details, see the OCP App SDK documentation.

  1. Check if the channel is ready 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 (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 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 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 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 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://.

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

🚧

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.

📘

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:

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: [email protected]
  categories:
    - Channel
  availability:
    - us
runtime: node18
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.

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

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:

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:

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:

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 tempaltes
   * @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. OCP fills these interfaces with the data provided by ODP users in campaign forms and passes them to your channel app in callback methods.

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;
  };
}
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.

/* 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>
`;
}