# Solutions By Text (SBT) Integration with Salesforce Marketing Cloud Technical Documentation

## Table of Contents

1. [Executive Summary](#1-executive-summary)
2. [System Architecture Overview](#2-system-architecture-overview)
3. [Repository Layout](#3-repository-layout)
4. [Custom Activities](#4-custom-activities)
5. [API Endpoints and Routes](#5-api-endpoints-and-routes)
6. [Data Flow and Integration](#6-data-flow-and-integration)
7. [Configuration Files](#7-configuration-files)
8. [Frontend Components](#8-frontend-components)
9. [Postmonger Integration](#9-postmonger-integration)
10. [Security Considerations](#10-security-considerations)
11. [Error Handling](#11-error-handling)
12. [Deployment and Hosting](#12-deployment-and-hosting)
13. [Known Deviations from Salesforce Specification](#13-known-deviations-from-salesforce-specification)
14. [Appendix](#14-appendix)

---

## 1. Executive Summary

### 1.1 Project Overview

Solutions By Text (SBT) is an SMS service provider that required a custom application for use within
Salesforce Marketing Cloud (SFMC). The objective was to enable SBT's customers to send large-scale
marketing campaign SMS messages from SFMC to their end customers using SBT's API infrastructure.

### 1.2 Solution Summary

The integration is implemented as a **custom middleware server** that acts as a secure, multi-tenant
bridge between Salesforce Marketing Cloud and the SBT REST API. The middleware handles:

- Per-tenant authentication and authorization
- Message formatting and personalization
- Template retrieval and variable substitution
- URL shortening (SmartURL)
- SBT API communication and token caching
- Reliable SMS delivery for bulk marketing campaigns

### 1.3 Key Features

| Feature | Description |
|---------|-------------|
| Plain Text SMS | Compose and send custom SMS messages with dynamic personalization |
| Template SMS | Use pre-defined SBT templates with `{variable}` substitution |
| Subscriber Opt-In | Add a contact to an SBT group and initiate opt-in consent |
| MMS Support | Attach media URLs for multimedia messaging |
| SmartURL | Create trackable shortened URLs for SMS messages |
| Dynamic Personalization | Insert contact and event data fields into messages |
| Multi-Tenancy | Per-business-unit credentials, groups, and JWT signing keys |
| Journey Builder Integration | Full integration with SFMC Journey Builder workflows |

---

## 2. System Architecture Overview

### 2.1 Three-Tier Architecture

```
+------------------------------------------+
|     SALESFORCE MARKETING CLOUD (SFMC)    |
|  +------------------------------------+  |
|  |         Journey Builder            |  |
|  |  +------------------------------+  |  |
|  |  |    Custom Activity iFrame    |  |  |
|  |  |    (Frontend Application)    |  |  |
|  |  +------------------------------+  |  |
|  +------------------------------------+  |
+------------|-----------------|-----------+
             |                 |
   Postmonger|                 | Signed JWT (server-to-server)
   (browser  |                 | at journey execution
    postMessage)               |
             |                 |
             v                 v
+------------------------------------------+
|          CUSTOM MIDDLEWARE SERVER        |
|  +------------------------------------+  |
|  |  - SFMC JWT verification           |  |
|  |  - fuel2token validation           |  |
|  |  - Business-unit resolution (MID)  |  |
|  |  - Message processing engine       |  |
|  |  - Template management             |  |
|  |  - URL shortening service          |  |
|  |  - Redis cache / replay protection |  |
|  |  - SBT API gateway                 |  |
|  +------------------------------------+  |
+---------------------|--------------------+
                      | REST API (HTTPS, Bearer)
                      v
+------------------------------------------+
|          SBT SMS PLATFORM                |
|  +------------------------------------+  |
|  |  - SMS Gateway                     |  |
|  |  - Message Delivery                |  |
|  |  - Carrier Integration             |  |
|  |  - Delivery Reports / Webhooks     |  |
|  +------------------------------------+  |
+------------------------------------------+
```

> **Note on the two arrows into the middleware.** These are distinct channels with distinct
> authentication and distinct callers, and conflating them is the most common source of confusion:
>
> - The **iFrame** calls `/activity/*` from the **marketer's browser**, authenticated with the SFMC
>   `fuel2token`.
> - **Journey execution** calls `/journey/execute/*` from **Salesforce's servers**, authenticated
>   with a JWT signed using the Installed Package secret. No browser is involved.

### 2.2 Component Responsibilities

| Component | Responsibility |
|-----------|---------------|
| **SFMC Journey Builder** | Campaign orchestration, contact management, journey triggers |
| **Custom Activity (Frontend)** | Configuration UI, field mapping, activity payload construction |
| **Middleware Server** | Authentication, tenant resolution, message processing, SBT API routing |
| **SBT Platform** | SMS delivery, carrier management, delivery reporting |

### 2.3 Technology Stack

**Frontend (Custom Activities)** — build-less, no `package.json`, no bundler, no tests:

| Library | Version | Delivery |
|---------|---------|----------|
| jQuery | 3.7.1 | CDN (`code.jquery.com`), SRI-pinned |
| RequireJS (AMD loader) | 2.3.7 | CDN (`cdnjs.cloudflare.com`), SRI-pinned |
| Bootstrap CSS | 5.3.3 | CDN (`cdn.jsdelivr.net`), SRI-pinned |
| Postmonger | 0.0.14 | Self-hosted per version folder (`postmonger.js`) |

Only Bootstrap's **CSS** is loaded; its JavaScript bundle is not used. All three activities use
jQuery — **Axios is no longer a dependency** (it was previously used by the template activity).

**Backend (Middleware Server):**
- Node.js / Express
- Redis (caching, JWT replay protection)
- `jose` for JWT verification
- REST architecture, HTTPS/TLS only

---

## 3. Repository Layout

### 3.1 Structure

The repository is organised as **three environments × three activities**. Environments differ
principally by the hardcoded middleware base URL compiled into `config.js`, `config.json`, and
`customActivity.js`.

```
sbt-mc-activity-new/
|
+-- dev/test/                    # SANDBOX — the active non-production environment
|   +-- optin/<version>/
|   +-- plain/<version>/
|   +-- template/<version>/
|
+-- mc/                          # LEGACY — do not use, see note below
|   +-- optin/<version>/
|   +-- plain-text-activity/<version>/
|   +-- template-text-activity/<version>/
|
+-- prod/                        # Production environment
|   +-- optin/v1/
|   +-- plain-text-activity/v1/
|   +-- template-text-activity/v1/
|
+-- images/
|   +-- sms.png                  # Shared activity icon
|
+-- README.md
+-- .gitignore
```

Every version folder contains the same six artifacts:

| File | Purpose |
|------|---------|
| `index.html` | Activity UI markup, CDN includes, RequireJS bootstrap |
| `customActivity.js` | AMD module: Postmonger lifecycle, UI logic, payload construction |
| `config.js` | AMD-wrapped copy of the activity descriptor |
| `config.json` | The SFMC activity descriptor consumed by Journey Builder |
| `postmonger.js` | Self-hosted Postmonger 0.0.14 |
| `images/sms.png` | Per-activity copy of the icon |

`config.js` and `config.json` hold the same descriptor and **must be kept in sync manually**. There
is no build step that derives one from the other.

**`mc/` is legacy and must not be used.** It targets a middleware host that has been retired. It is
retained only for reference; all current work happens in `dev/test/` and `prod/`.

### 3.2 Versioning Convention

**Versioning is the cache-busting and backward-compatibility mechanism.** SFMC caches activity
assets aggressively, and live journeys hold references to the version they were configured against.

Consequently:

- A change is shipped by creating a **new version folder**, never by editing a deployed folder in
  place.
- Superseded version folders are **retained deliberately** so that already-published journeys keep
  functioning.
- Edits should be scoped to a single environment at a time.
- Production currently runs `v1` of all three activities. Non-production environments carry higher
  version numbers because iteration happens there first; the numbering is not comparable across
  environments.

### 3.3 Caching: What Actually Invalidates

Three independent caches sit between an edit and what a marketer sees. Most "my change didn't take
effect" reports are a matter of identifying which one is responsible — they are cleared by
different actions, and clearing the wrong one wastes time.

| Layer | What it holds | Cleared by |
|-------|---------------|------------|
| **1. Journey definition snapshot** (SFMC) | A frozen copy of the activity's `inArguments`, `outArguments`, and endpoint URLs, taken when the activity was saved into a journey | Nothing server-side. Delete the activity from the canvas, re-add it, republish |
| **2. Descriptor fetch** (SFMC) | `config.json`, retrieved by Salesforce from the activity host | A new endpoint URL — in practice a new version folder registered on the Installed Package |
| **3. iframe assets** (browser) | `index.html`, `customActivity.js`, `config.js`, `postmonger.js`, the icon | HTTP cache headers on the activity host; a hard refresh clears it for one user only |

**Layer 1 is why editing a deployed folder cannot fix a live journey.** Once an activity is saved
into a journey, SFMC holds its own copy of the descriptor's arguments and endpoints. Later changes
to `config.json` on the host never reach it. This is the backward-compatibility half of
[3.2](#32-versioning-convention), and the reason superseded version folders are retained.

**Layer 2 has two parts, and only one of them yields to a refresh.** Salesforce fetches
`config.json` from the activity host when it renders the activity onto a fresh canvas — that fetch
is what a delete-and-re-add relies on. The open Journey Builder session then holds the descriptor in
memory, so a page reload is genuinely required before a re-added activity picks up a new one.

But Salesforce also retains the descriptor server-side, beyond the reach of any client-side refresh,
and that is the part that defeats reloading. The git history records commits titled *"Change version
for cache"* — reloading had already been tried. So: reload the page for the session copy, and bump
the version folder for the server-side copy. The first alone is not enough.

**Layer 3 is the only layer under direct control**, through cache headers on the activity host.
These are currently unset, so browsers apply their own heuristics. Since version folders already
provide cache-busting by changing every asset URL, the safe setting is a short `max-age` (or
`no-cache`) on `.js` and `.html` within a version folder — see
[12.1](#121-hosting-requirements).

**A new version folder is the only single action that clears layers 2 and 3 together**, because
every asset URL beneath it changes at once. Note that a relative `metaData.icon` (deviation 4 in
[Section 13](#13-known-deviations-from-salesforce-specification)) resolves against the version
folder and therefore rotates with it, while an absolute one does not.

#### Making a change take effect

Work down this list. Stop at the first step that covers what was changed.

1. **UI or logic only** (`index.html`, `customActivity.js`, `config.js`) — hard-refresh the browser
   (`Ctrl + F5` / `Cmd + Shift + R`). Clears layer 3 for you, but only for you; other marketers keep
   the stale copy until their own cache expires. Adequate for verifying a fix, not for shipping one.
2. **Descriptor changed** (`config.json` — arguments, endpoints, modal size, name) — create a new
   version folder, update the endpoint URL on the Installed Package, then reload Journey Builder.
   Clears layers 2 and 3 together.
3. **A journey already uses the activity** — delete the activity from the canvas, reload the page,
   drag it back on, and republish. Nothing short of this replaces the layer 1 snapshot, and it is
   required per journey.

Step 3 is not optional when the descriptor's arguments or endpoints changed: existing journeys keep
calling the old endpoint with the old argument set until their activities are re-added, and they
will not error while doing so.

---

## 4. Custom Activities

Three activities are deployed. All three share the same shell: a Bootstrap-styled modal, a required
**Group** selector populated from the middleware, and a Postmonger save cycle.

### 4.1 Group Selection (common to all three)

Every activity requires the marketer to select an **SBT Group** before the Next/Done button
enables. Groups are fetched from `GET /activity/bu-groups` once both the `MID` and `fuel2token`
have arrived from Postmonger. The selected `sbt_group_id` is written into the activity's
`inArguments` and is what authorizes the send on the SBT side.

| Element ID | Type | Purpose |
|-----------|------|---------|
| `#groupSelect` | Select | SBT group selection |
| `#groupError` / `#groupsError` | Div | Group loading / validation error text |

### 4.2 Plain Text Activity

**Purpose:** Compose a free-form SMS message with dynamic personalization.

**Activity name in Journey Builder:** `SBT Plain Message`
**Config modal:** 800 × 450

**Key features:** free-form composition, dynamic field insertion at cursor, message preview with
sample data, SmartURL creation and insertion, MMS media URL.

**User Interface Elements:**

| Element ID | Type | Purpose |
|-----------|------|---------|
| `#step1` | Div | Step container |
| `#mess` | Textarea | Message composition area |
| `#fieldDropdown` | Select | Dynamic field selection |
| `#insertFieldBtn` | Button | Insert selected field at cursor |
| `#previewContent` | Div | Preview rendered with sample data |
| `#media` | Input | MMS media URL |
| `#shortUrlRow` | Div | SmartURL controls container |
| `#longUrl` | Input | URL to shorten |
| `#shortenBtn` | Button | Trigger URL shortening |
| `#shortUrl` | Input | Resulting shortened URL |
| `#insertShortUrlBtn` | Button | Insert short URL into message |
| `#shortUrlError` | Div | Shortening error text |
| `#setmess` | Button | Confirm and save message |

**Behavioural note:** the activity strips a company-name prefix and the opt-out `helpText`
(`"To opt out reply STOP."`) from the saved message body for backward compatibility with messages
configured under earlier versions.

**Available Dynamic Fields**

*Contact fields:*

| Field Name | Token |
|------------|-------|
| Contact Key | `{{Contact.Key}}` |
| Email | `{{Contact.Attribute.Email}}` |
| First Name | `{{Contact.Attribute.FirstName}}` |
| Last Name | `{{Contact.Attribute.LastName}}` |
| Phone | `{{Contact.Attribute.Phone}}` |
| Mobile Number | `{{Contact.Attribute.MobileNumber}}` |

*Event data fields* are generated dynamically from the journey's entry event definition:

| Field Pattern | Example |
|--------------|---------|
| `{{Event.[eventDefinitionKey].[FieldName]}}` | `{{Event.APIEvent-123.Email}}` |

---

### 4.3 Template Text Activity

**Purpose:** Send an SMS using a pre-defined SBT template with per-variable mapping.

**Activity name in Journey Builder:** `SBT Template Text`
**Config modal:** 1000 × 450 (wider than the others to accommodate the variable-mapping grid)

**Key features:** template selection scoped to the chosen group, automatic `{variable}` extraction,
per-variable mapping to either a dynamic token or a static value, template preview, SmartURL, MMS.

**User Interface Elements:**

| Element ID | Type | Purpose |
|-----------|------|---------|
| `#step-template` | Div | Step container |
| `#templateSelect` | Select | Template selection dropdown |
| `#templatePreview` | Div | Rendered template message text |
| `#templatesError` | Div | Template loading error text |
| `#variablesBlock` | Div | Variable mapping section wrapper |
| `#variablesContainer` | Div | Dynamically generated variable inputs |
| `#media` | Input | MMS media URL |
| `#tmplShortUrlRow` | Div | SmartURL controls container |
| `#tmplLongUrl` | Input | URL to shorten |
| `#tmplShortenBtn` | Button | Trigger URL shortening |
| `#tmplShortUrl` | Input | Resulting shortened URL |
| `#tmplShortUrlError` | Div | Shortening error text |
| `#validationError` | Div | Validation error text |

**Variable mapping.** Templates contain placeholders in `{variableName}` form. Each extracted
variable is mapped to either a **dynamic field** (a contact or event token) or a **static value**.

Example template:

```
Hello {FirstName}, your account balance is {Balance}. Visit {Link} for details.
```

Variables extracted: `FirstName`, `Balance`, `Link`.

Templates are loaded from `GET /activity/templates?group_id=<sbt_group_id>` and therefore only
become available after a group is selected.

---

### 4.4 Subscriber Opt-In Activity

**Purpose:** Add the journey contact to an SBT group and initiate the opt-in consent flow. This is
a subscription-management activity, not a messaging activity.

**Activity name in Journey Builder:** `SBT Subscriber Opt-In`
**Description:** `Manage SBT opt-in subscriber by group`
**Config modal:** 800 × 420

**User Interface Elements:**

| Element ID | Type | Purpose |
|-----------|------|---------|
| `#step1` | Div | Step container |
| `#groupSelect` | Select | Target SBT group |
| `#resendVerification` | Checkbox | Re-send verification to an existing subscriber |
| `#defaultOptInMethod` | Checkbox | Use the group's system-default opt-in method |
| `#optInTypeWrapper` | Div | Shown only when the default method is unchecked |
| `#optInTypeSelect` | Select | Explicit opt-in type |
| `#optInTypeError` | Div | Opt-in type validation error |
| `#configSummary` | Div | Read-back summary of the configuration |

**Validation.** Done is enabled only when a group is selected **and** either the default opt-in
method is in use or an explicit opt-in type has been chosen.

**Activity naming.** The saved activity is renamed to `Opt in Subscriber for <group name>` so the
journey canvas shows the target group.

**Saved `inArguments`** (single flattened object):

| Key | Value |
|-----|-------|
| `subscriberKey` | `{{Contact.Key}}` |
| `Phone` | `{{Event.[eventKey].Phone}}` |
| `Email` | `{{Event.[eventKey].Email}}` |
| `Id` | `{{Event.[eventKey].Id}}` |
| `groupId` | Selected `sbt_group_id` |
| `resendVerification` | Boolean |
| `useDefaultOptInMethod` | Boolean |
| `optInType` | String, or `null` when the default method is used |
| `mid` | SFMC MID, from Postmonger |
| `tssd` | SFMC tenant subdomain, derived from Postmonger endpoints |

---

## 5. API Endpoints and Routes

### 5.1 Middleware Base URL

Middleware host names are **not recorded in this document** for any environment. Each environment has
its own; they live in the deployment runbook.

Throughout this document `<backend-base-url>` stands in for the real value. To read the actual host
for a given environment, look at either the `API_BASE` constant at the top of that version folder's
`customActivity.js`, or the `url` fields in its `config.json` — the two must agree, and they are the
only authority.

Everything below documents **paths relative to that host**, which are identical across environments.

### 5.2 Route Mounting

| Mount | Router | Caller |
|-------|--------|--------|
| `/journey` | Journey execution + lifecycle | Salesforce servers (JWT) |
| `/activity` | Custom activity configuration APIs | Marketer's browser (fuel2token) |

### 5.3 Journey Execution Endpoints

Called server-to-server by Journey Builder when a contact reaches the activity. All are `POST`, all
carry `Content-Type: application/jwt` with the raw JWT as the request body.

| Endpoint | Activity |
|----------|----------|
| `/journey/execute/plain` | Plain Text |
| `/journey/execute/template` | Template Text |
| `/journey/execute/optin-subscriber` | Subscriber Opt-In |

### 5.4 Journey Lifecycle Endpoints

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/journey/save` | POST | Activity configuration saved |
| `/journey/publish` | POST | Journey published |
| `/journey/validate` | POST | Configuration validated |
| `/journey/stop` | POST | Journey stopped |

All four are served by a single parameterised handler (`POST /journey/:type`) that checks `:type`
against `["validate", "publish", "save", "stop"]`, returns `204 No Content`, and does nothing else.
Unrecognised values are rejected with `400 Bad Request`.

**These handlers are deliberate no-ops.** They read no body, touch no database, and change no state.
That is a legitimate implementation — Journey Builder only requires a 2xx — and it is why the lack
of authentication on them is not currently a meaningful exposure: an unauthenticated caller can
elicit a `204` and nothing more.

### 5.5 Activity Configuration Endpoints

Called from the iFrame by the marketer's browser. All require
`Authorization: Bearer <fuel2token>` and an `X-MID` header.

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/activity/bu-groups` | GET | SBT groups available to the calling business unit |
| `/activity/templates` | GET | SBT templates for a group (`?group_id=`) |
| `/activity/short-url` | POST | Create a SmartURL (`{ longUrl, sbt_group_id }`) |

Group lookups are cached in Redis to avoid re-querying SBT on every modal open.

---

### 5.6 Request and Response Schemas

#### 5.6.1 Journey Execute — request

Journey Builder sends the JWT as the raw request body:

```http
POST /journey/execute/plain HTTP/1.1
Host: <backend-base-url>
Content-Type: application/jwt

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbkFyZ3VtZW50cyI6W...
```

The decoded payload contains the journey context plus the configured `inArguments`:

```json
{
  "keyValue": "CONTACT-ABC123",
  "activityInstanceId": "a1b2c3d4-...",
  "journeyId": "e5f6a7b8-...",
  "activityId": "...",
  "definitionInstanceId": "...",
  "inArguments": [
    {
      "message": "Hello John, your order #ORD-789 is ready!",
      "media": "https://example.com/image.jpg",
      "groupId": "<sbt_group_id>",
      "subscriberKey": "CONTACT-ABC123",
      "Phone": "15551234567",
      "Email": "john@example.com",
      "Id": "...",
      "mid": "<sfmc mid>",
      "tssd": "<sfmc subdomain>"
    }
  ],
  "outArguments": []
}
```

Personalization tokens are substituted by SFMC **before** the JWT is signed, so the middleware
receives literal values, never `{{...}}` strings.

#### 5.6.2 Journey Execute — response

`202 Accepted`:

```json
{
  "success": true,
  "message": "Plain SMS journey executed"
}
```

**`202`, not `200` — the send has been queued, not delivered.** All three execute handlers validate
the payload, push onto the BullMQ queue, and return immediately; the SBT API call happens in a
worker. Journey Builder treats any 2xx as success, so a contact advances past the activity as soon
as the message is enqueued. Delivery failures surface later through the SBT status webhook, never in
this response.

The `outArguments` declared in `config.json` (`success`, and `message` for opt-in) are what Journey
Builder writes back into the journey's data for use by downstream decision splits.

#### 5.6.3 `GET /activity/bu-groups`

```json
{
  "success": true,
  "message": "Request successful",
  "results": {
    "mid": "<sfmc mid>",
    "bu_display_name": "<business unit name>",
    "company_name": "<company name>",
    "groups": [
      { "sbt_group_id": "<group id>", "name": "Marketing Group" },
      { "sbt_group_id": "<group id>", "name": "Transactional Group" }
    ]
  }
}
```

All `/activity/*` responses use the shared `successResponse()` envelope — `success`, `message`, and
`results` — so the payload is always one level deeper than it appears. The activities read
`resp.results.groups`.

#### 5.6.4 `GET /activity/templates?group_id=<id>`

```json
{
  "data": [
    { "id": 12345, "name": "Welcome Message",
      "message": "Hello {FirstName}, welcome to {CompanyName}!", "status": "active" },
    { "id": 12346, "name": "Payment Reminder",
      "message": "Hi {FirstName}, your payment of {Amount} is due on {DueDate}.", "status": "active" }
  ]
}
```

#### 5.6.5 `POST /activity/short-url`

Request:

```json
{ "longUrl": "https://example.com/very/long/path.html?param=value", "sbt_group_id": "<group id>" }
```

Response:

```json
{ "ok": true, "shortUrl": "https://sbt.ly/abc123" }
```

---

## 6. Data Flow and Integration

### 6.1 Configuration Time (marketer in Journey Builder)

```
Marketer drags SBT activity onto the journey canvas
        |
        v
Journey Builder opens the config modal (iFrame -> index.html)
        |
        v
RequireJS loads customActivity.js, which opens a Postmonger session
        |
        +-- trigger "ready"
        +-- trigger "requestTokens"                 --> requestedTokens  { fuel2token, MID }
        +-- trigger "requestEndpoints"              --> requestedEndpoints { authTSSD | restHost }
        +-- trigger "requestSchema"                 --> requestedSchema
        +-- trigger "requestTriggerEventDefinition" --> requestedTriggerEventDefinition
        +-- trigger "requestInteraction"            --> requestedInteraction
        |
        v
Once MID + fuel2token are present: GET /activity/bu-groups
        |
        v
Marketer selects group, composes message / picks template / sets opt-in options
        |
        v
Journey Builder emits "clickedNext" --> save()
        |
        v
trigger "updateActivity" with the assembled payload
(inArguments, name, metaData.isConfigured = true)
```

### 6.2 Runtime (contact enters the journey)

```
Contact enters journey via its entry event
        |
        v
SFMC substitutes personalization tokens with real values
   {{Contact.Key}}             -> "CONTACT-ABC123"
   {{Event.key.Phone}}         -> "15551234567"
   {{Contact.Attribute.First}} -> "John"
        |
        v
SFMC signs the resulting payload as a JWT using the Installed Package secret
        |
        v
POST to the configured execute URL, Content-Type: application/jwt
        |
        v
+-- MIDDLEWARE -----------------------------------------+
|  1. Read the raw JWT body                             |
|  2. Decode WITHOUT verifying, only to read `mid`      |
|  3. Look up that tenant's signing key                 |
|  4. Verify the signature (HS256) with that key        |
|  5. Replay / duplicate check                          |
|  6. Validate the payload against the activity schema  |
|  7. Resolve SBT credentials for the business unit     |
|  8. Call the SBT API                                  |
|  9. Return outArguments to Journey Builder            |
+-------------------------------------------------------+
        |
        v
SBT platform queues, routes to carrier, delivers, and posts
delivery status back to the webhook endpoint
```

### 6.3 Timeout and Retry Behaviour

All three activities configure identical resilience settings:

| Property | Value | Salesforce limit |
|----------|-------|------------------|
| `timeout` | 30000 ms | 1000–100000, default 20000 |
| `retryCount` | 5 | 0–5, default 0 (**at maximum**) |
| `retryDelay` | 10000 ms | 0–10000, default 1000 (**at maximum**) |
| `concurrentRequests` | not set | 1–10, default 6 |

Because `retryCount` is at the maximum, a single contact can produce up to **six** delivery
attempts against the execute endpoint. This is why the duplicate detection described in
[10.2](#102-journey-execution-authentication) matters: without it, a slow response followed by a
retry would send the same SMS more than once.

---

## 7. Configuration Files

### 7.1 `config.json` Structure

`config.json` is the activity descriptor consumed by Journey Builder. The following is the
production Subscriber Opt-In descriptor, abridged for readability:

```json
{
  "workflowApiVersion": "1.1",
  "metaData": {
    "icon": "<custom-activity-app-host>/images/sms.png",
    "category": "message"
  },
  "type": "REST",
  "lang": {
    "en-US": {
      "name": "SBT Subscriber Opt-In",
      "description": "Manage SBT opt-in subscriber by group"
    }
  },
  "arguments": {
    "execute": {
      "inArguments": [
        { "groupId": "" },
        { "resendVerification": false },
        { "subscriberKey": "{{Contact.Key}}" },
        { "Phone": "{{InteractionDefaults.Phone}}" },
        { "Email": "{{InteractionDefaults.Email}}" },
        { "mid": "" },
        { "tssd": "" }
      ],
      "outArguments": [
        { "success": "" },
        { "message": "" }
      ],
      "url": "https://<backend-base-url>/journey/execute/optin-subscriber",
      "timeout": 30000,
      "retryCount": 5,
      "retryDelay": 10000,
      "useJwt": true
    }
  },
  "configurationArguments": {
    "save":     { "url": "https://<backend-base-url>/journey/save" },
    "publish":  { "url": "https://<backend-base-url>/journey/publish" },
    "validate": { "url": "https://<backend-base-url>/journey/validate" },
    "stop":     { "url": "https://<backend-base-url>/journey/stop" }
  },
  "userInterfaces": {
    "configModal": { "height": 420, "width": 800, "fullscreen": false }
  },
  "schema": {
    "arguments": {
      "execute": {
        "inArguments": [
          { "groupId": { "dataType": "Text",  "isNullable": false, "direction": "in" } },
          { "Phone":   { "dataType": "Phone", "isNullable": false, "direction": "in" } },
          { "Email":   { "dataType": "Email", "isNullable": true,  "direction": "in" } }
        ],
        "outArguments": [
          { "success": { "dataType": "Boolean", "direction": "out", "access": "visible" } },
          { "message": { "dataType": "Text",    "direction": "out", "access": "visible" } }
        ]
      }
    }
  }
}
```

> **`inArguments` in `config.json` are defaults, not the final contract.** The plain and template
> activities declare only `emailAddress` and `phoneNumber` here; `customActivity.js` replaces the
> whole `inArguments` array at save time with the real payload. Only the opt-in activity declares
> its full argument set in the descriptor. Do not read `config.json` alone to determine what the
> execute endpoint receives — read `save()` in the corresponding `customActivity.js`.

### 7.2 Salesforce `config.json` Specification

Reference: [Custom Activity Configuration](https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/custom-activity-config.html)

**Top-level keys**

| Property | Required | Notes |
|----------|----------|-------|
| `workflowApiVersion` | Yes | Accepted values `1.1`, `1.0`, `0.5`. This project uses `1.1` |
| `metaData` | Yes | `icon`, `category`, `expressionBuilderPrefix`, `isConfigured`, `configurationDisabled`, `configOnDrop` |
| `type` | Yes | `REST` or `RestDecision`. This project uses `REST` |
| `lang` | Yes | Localized name/description keyed by BCP 47 locale code |
| `arguments` | Yes | Contains the `execute` object |
| `configurationArguments` | Yes | `applicationExtensionKey` plus lifecycle action objects |
| `userInterfaces` | Yes | `configModal` with `url`, `height`, `width`, `fullscreen` |
| `wizardSteps` | No | Step definitions for multi-step configuration |
| `schema` | No | Data types for in/out arguments |
| `copySettings` | No | `allowCopy` (Boolean), `ignoreArguments` (Array) |

**`arguments.execute` properties**

| Property | Type | Range / Default |
|----------|------|-----------------|
| `inArguments` | Array | Data passed to the endpoint on contact arrival |
| `outArguments` | Array | Fields the application returns to Journey Builder |
| `url` | String | The application execute endpoint |
| `timeout` | Integer | 1000–100000, default 20000 (ms) |
| `retryCount` | Integer | 0–5, default 0 |
| `retryDelay` | Integer | 0–10000, default 1000 (ms) |
| `concurrentRequests` | Integer | 1–10, default 6 |
| `useJwt` | Boolean | Encode the request as a signed JWT. Set to `true` in this project |
| `securityOptions` | Object | OAuth 2.0 bearer configuration. See [10.5](#105-optional-oauth-20-bearer-tokens) |

**`configurationArguments` properties**

| Property | Notes |
|----------|-------|
| `applicationExtensionKey` | **Required.** Identifies this instance of the activity. See [Section 13](#13-known-deviations-from-salesforce-specification) |
| `save`, `publish`, `unpublish`, `validate`, `stop` | Lifecycle action objects |

Each lifecycle action object accepts:

| Property | Type | Description |
|----------|------|-------------|
| `url` | String | HTTPS endpoint receiving an HTTP POST |
| `body` | String | Additional JSON keys/values merged into the request body |
| `headers` | Object | Additional request headers |
| `useJwt` | Boolean | If true, the request is encoded as a JWT |
| `customerKey` | String | External key identifying the JWT signing credential |
| `securityOptions` | Object | OAuth 2.0 bearer configuration: `securityType`, `securityContextKey` |

**`schema` argument properties:** `dataType`, `isNullable`, `direction`, `access`.

---

## 8. Frontend Components

### 8.1 RequireJS Module Structure

`index.html` loads RequireJS, which loads `config.js`, which in turn requires `customActivity`:

```javascript
(function () {
  var config = { baseUrl: "" };
  var dependencies = ["customActivity"];
  require(config, dependencies);
})();
```

`customActivity.js` is an AMD module depending on the locally hosted Postmonger:

```javascript
"use strict";
define(["postmonger"], function (Postmonger) {
  const connection = new Postmonger.Session();
  const API_BASE = "https://<backend-base-url>/activity";
  // ... lifecycle handlers, UI logic, save()
});
```

### 8.2 Postmonger Library

**Version:** 0.0.14, self-hosted per version folder.

Postmonger wraps the browser `postMessage` API to provide an event-based channel between the custom
activity iFrame and the Journey Builder parent window.

| Class | Purpose |
|-------|---------|
| `Postmonger.Session` | Main communication session with Journey Builder |
| `Postmonger.Connection` | Low-level connection management |
| `Postmonger.Events` | Event handling system |

### 8.3 UI Styling

**SBT brand colours (CSS custom properties):**

| Variable | Value | Usage |
|-------------|-------|-------|
| `--sbt-primary` | `#0F3D91` | Primary brand colour |
| `--sbt-primary-600` | `#0b2e6d` | Darker primary variant |
| `--sbt-accent` | `#28C76F` | Accent / success |
| `--sbt-bg` | `#f5f7fb` | Background |
| `--sbt-card` | `#ffffff` | Card background |
| `--sbt-border` | `#e7ebf3` | Borders |
| `--sbt-muted` | `#6b7280` | Muted text |
| `--sbt-text` | `#1f2937` | Primary text |

---

## 9. Postmonger Integration

Reference: [Postmonger Events Reference](https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/using-postmonger.html)

### 9.1 Events Triggered by the Custom Activity

| Event | Payload | Used by |
|-------|---------|---------|
| `ready` | none | All three |
| `requestTokens` | none | All three |
| `requestEndpoints` | none | All three |
| `requestTriggerEventDefinition` | none | All three |
| `requestInteraction` | none | Plain, template |
| `requestSchema` | none | Plain, template (see 9.3) |
| `updateButton` | `{ button, text, visible, enabled }` | All three |
| `updateActivity` | Activity definition with `metaData.isConfigured` | All three |
| `prevStep` | none | Plain, optin |

### 9.2 Events Broadcast by Journey Builder

| Event | Documented payload | Handled by |
|-------|--------------------|--------------|
| `initActivity` | `{ name, metaData, arguments, configurationArguments, outcomes, errors }` | All three |
| `requestedTokens` | `{ token, fuel2token }` | All three |
| `requestedEndpoints` | `{ restHost }` | All three |
| `requestedTriggerEventDefinition` | Event definition object, or `null` | All three |
| `requestedInteraction` | `{ activities, defaults, ... }` per the Journey Spec | Plain, template |
| `clickedNext` | none | All three |
| `clickedBack` | none | All three |
| `gotoStep` | `{ key, label }` | All three |

### 9.3 Undocumented Payload Fields This Code Depends On

Three dependencies sit outside Salesforce's published contract. They work today, but they are not
guaranteed by the platform and should be the first suspects if an activity breaks after an SFMC
release.

| Dependency | Documented as | What the code does |
|------------|---------------|--------------------|
| `requestedTokens.MID` | Payload is documented as `{ token, fuel2token }` — `MID` is not listed | Read directly as `t.MID`; every `/activity/*` call is abandoned without it |
| `requestedEndpoints.authTSSD` | Payload is documented as `{ restHost }` only | Preferred source for the tenant subdomain, with `restHost` parsing as fallback |
| `requestSchema` / `requestedSchema` | Not present in the published event tables | Used by the plain and template activities to build the dynamic-field dropdown |

The `restHost` fallback in the `requestedEndpoints` handler is the mitigation for one of these.
There is no fallback for `MID`, which is the more fragile dependency.

**Tenant subdomain derivation:**

```javascript
connection.on("requestedEndpoints", function (endpoints) {
  if (endpoints?.authTSSD) {
    tssd = new URL(endpoints.authTSSD).hostname.split(".")[0];
  } else if (endpoints?.restHost) {
    tssd = String(endpoints.restHost).split(".")[1] || "";
  }
});
```

---

## 10. Security Considerations

### 10.1 Authentication Summary

| Channel | Caller | Mechanism |
|---------|--------|-----------|
| Journey Builder to iFrame | Marketer's browser | Postmonger session over `postMessage` |
| iFrame to `/activity/*` | Marketer's browser | `Authorization: Bearer <fuel2token>` + `X-MID` |
| Journey Builder to `/journey/execute/*` | Salesforce servers | HS256 JWT, per-tenant signing key |
| Middleware to SBT | Middleware | OAuth 2.0 bearer token, cached for its 1-hour lifetime |

### 10.2 Journey Execution Authentication

The execute endpoints receive requests from Salesforce's servers, not from a browser. Arbitrary
custom headers, static API keys, and client certificates cannot be attached to these requests — the
only two authentication mechanisms the platform offers are JWT signing (`useJwt`) and OAuth 2.0
bearer tokens (`securityOptions`, see [10.5](#105-optional-oauth-20-bearer-tokens)). This project
uses JWT signing, which means the signature is currently the sole proof of origin and must be
verified rather than merely decoded.

The middleware:

1. Reads the raw JWT from the request body (`Content-Type: application/jwt`).
2. Decodes it **without verifying** for the sole purpose of reading `mid` from `inArguments`, so it
   knows which tenant's key to use. Nothing from this decode is trusted.
3. Loads that business unit's signing key (stored encrypted at rest) and rejects the request if no
   key is configured for the MID.
4. Verifies the signature with HS256 against that key. Verification failure returns `401`.
5. Applies replay protection: a short-lived, single-use Redis key derived from the journey-execute
   identity. A request recognised as a duplicate returns `409`.
6. Replaces the request body with the verified payload before any handler sees it.

The key derivation and window are deliberately not reproduced here; read
`sfmcJwtAuth` in the middleware for the current values.

**On the choice of signing key.** Salesforce supports two signing approaches: an external
`customerKey` passed in the request body, or the Installed Package's **JWT Signing Secret**, which
the documentation describes as a fallback key "useful when the customer key isn't provided,
particularly for multi-user integrations." This project uses the JWT Signing Secret, held per
business unit — the correct choice for a multi-tenant integration, since each tenant's Installed
Package carries its own secret and a key compromise is contained to one business unit.

**Replay protection is availability-biased.** It is a secondary control layered on top of signature
verification, and it is deliberately not allowed to halt journey delivery if the cache tier is
degraded. That trade-off is intentional; the behaviour and its consequences are documented in the
operations runbook rather than here.

### 10.3 Activity Configuration Authentication

`/activity/*` requests carry the SFMC `fuel2token` obtained from Postmonger. The middleware
validates the token against Salesforce and resolves the business unit from the MID.

**The client-supplied `X-MID` is not trusted.** It is checked against the token's own context, so a
caller cannot present a valid token for one business unit alongside another unit's MID to read that
unit's groups or templates.

### 10.4 Frontend Security Posture

- **No credentials in the frontend.** All SBT API keys and tenant secrets live on the middleware.
  The `fuel2token` is handed to the page at runtime by Postmonger; it is not present in any served
  file.
- **Subresource Integrity.** All three CDN includes (jQuery, RequireJS, Bootstrap CSS) carry
  `integrity` and `crossorigin="anonymous"` attributes in the deployed activities. This matters
  because the iFrame holds a live `fuel2token` while it runs, so a compromised CDN response would
  execute in a privileged context.
- **The activity source is public by design.** The iFrame is loaded by the marketer's browser and
  `config.json` is fetched by Salesforce's servers, so neither can sit behind a login or an IP
  allowlist. Anyone who can open the activity can read its JavaScript. This is acceptable only
  because nothing secret is in it; the rule that nothing sensitive may be added to a served file is
  therefore load-bearing, not stylistic.
- **Recommended response headers** for the activity hosts:
  `Content-Security-Policy: frame-ancestors` limited to the SFMC application domains, and
  `X-Robots-Tag: noindex, nofollow`.

### 10.5 Optional: OAuth 2.0 Bearer Tokens

Reference: [Secure a Custom Activity Using OAuth 2.0](https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/secure-custom-activity-using-oauth.html)

**Not implemented.** Recorded because it is the second of the two authentication mechanisms
Marketing Cloud offers for custom activity requests ([10.2](#102-journey-execution-authentication)),
and knowing it exists is necessary to read the `securityOptions` fields in the descriptor spec
([7.2](#72-salesforce-configjson-specification)).

Marketing Cloud can obtain a bearer token from a token-exchange endpoint and attach it to outgoing
custom activity requests automatically. Configuration:

1. In SFMC Setup, under **Data Management > Key Management**, create a key of type
   **Security Context**, choosing a grant type:
   - **Client Credentials** — server-to-server using a client key and secret
   - **Client Credentials with JWT assertion** — using a signed JWT private key
   - **Authorization grant with JWT assertion** — for single sign-on scenarios
2. Reference that key from the descriptor:

```json
"securityOptions": {
  "securityType": "securityContext",
  "securityContextKey": "<externalKeyName>"
}
```

`securityOptions` may be set on `arguments.execute` **and** on the `configurationArguments`
lifecycle actions (`save`, `publish`, `validate`).

**Not used here.** The execute endpoints are protected by per-tenant JWT verification, and the
lifecycle endpoints are no-ops with nothing to protect ([5.4](#54-journey-lifecycle-endpoints)).
Between the two mechanisms, `useJwt` is the lighter one: it reuses the per-tenant signing secret
already on file, whereas OAuth requires a Security Context key and a token-exchange endpoint.

**What it does not do.** OAuth authenticates the *caller*; it does not make the endpoints private.
Combined with JWT it is defence in depth, not a replacement for either.

### 10.6 Data Protection

- Personalization tokens are substituted by SFMC at runtime; the middleware receives resolved values
  inside a signed JWT.
- No personal data is written to `localStorage`, `sessionStorage`, or cookies by the activities.
- All transport is HTTPS/TLS.
- Contact data is not logged to the browser console in production.

---

## 11. Error Handling

### 11.1 Frontend

Group loading failure (all three activities):

```javascript
.fail(function (xhr, status, err) {
  console.error("Get groups error:", status, err, xhr?.responseText);
  $("#groupError").text("Failed to load groups.");
  $sel.empty().append('<option value="">Failed to load groups</option>');
  $sel.prop("disabled", false);
  updateNextButtonState();
});
```

The Next/Done button stays disabled whenever a required selection is missing, so a failed group load
cannot produce a half-configured activity.

### 11.2 Validation Messages

| Validation | Message | Element |
|------------|---------|---------|
| No group selected | "Please select a group before proceeding." / "Please select a group." | `#groupError` |
| Group load failure | "Failed to load groups." | `#groupError` / `#groupsError` |
| No opt-in type when default is off | "Please select an OptIn Type." | `#optInTypeError` |
| No template selected | "Please select a template." | `#validationError` |
| Invalid media URL | "Media URL must start with http:// or https://" | `#validationError` |
| Template load failure | "Failed to load templates." | `#templatesError` |
| Empty URL to shorten | "Please enter a URL to shorten." | `#shortUrlError` |
| Shortening attempted before a group is chosen | "Please select a group before shortening a URL." | `#shortUrlError` |
| SmartURL creation failure | "Failed to shorten URL." | `#shortUrlError` |
| Postmonger did not supply `MID` | "Missing MID." | `#groupError` |
| Postmonger did not supply `fuel2token` | "Missing SFMC token." | `#groupError` |

**Server messages take precedence.** The group and template loaders render
`error_?.responseJSON?.message || "<fallback>"`, so an `APIException` message raised by the
middleware surfaces directly in the marketer's config modal. Messages returned from `/activity/*`
are therefore user-facing text, not just log material — keep them intelligible to a marketer and
free of internal detail.

The last two rows are the visible symptom of the undocumented-`MID` dependency in
[9.3](#93-undocumented-payload-fields-this-code-depends-on): if Salesforce ever stops sending `MID`
in the `requestedTokens` payload, every activity fails at this point with "Missing MID."

### 11.3 Middleware Error Responses

| Status | Condition |
|--------|-----------|
| `202` | Journey execute accepted and queued (success) |
| `204` | Lifecycle event acknowledged (`save`/`publish`/`validate`/`stop`) |
| `400` | Missing MID, unrecognised lifecycle `:type`, schema validation failure |
| `401` | Missing/malformed JWT, unknown tenant, signature verification failure, invalid or expired `fuel2token` |
| `409` | Duplicate journey execute (replay detected) |
| `500` | Tenant signing key could not be loaded |

Journey execute failures are logged with `mid`, `journeyId`, and `activityInstanceId` for
correlation against the Journey Builder activity log.

---

## 12. Deployment and Hosting

### 12.1 Hosting Requirements

**Frontend (custom activities):**
- Static file hosting over HTTPS with a valid certificate
- No build step — files are served exactly as they appear in the repository
- Directory listing disabled
- CORS headers permitting the SFMC application domains
- Explicit cache headers on activity assets (see below)

**Cache headers.** Currently unset, so browsers apply their own heuristics to `customActivity.js`
and `index.html` — the browser-side layer described in [3.3](#33-caching-what-actually-invalidates).
Because version folders already provide cache-busting, assets should be served with a short
lifetime rather than a long one:

```nginx
location ~* \.(js|html)$ {
    add_header Cache-Control "no-cache";
}
```

`no-cache` permits caching but forces revalidation, so a republished version folder is picked up
without a hard refresh. Do not apply a long `max-age` here: it compounds layer 3 with layers 1 and 2
and makes stale-activity reports much harder to diagnose.

**Environments:** production and sandbox have separate servers, each fronted by nginx.

**The document root is currently the repository root**, so the production host serves every
environment tree — `dev/`, `mc/`, and `prod/` alike — not just the production one. The
access controls that depend on it are covered in [12.4](#124-preventing-public-access).

### 12.2 External Dependencies (CDN)

| Resource | Version | Host | SRI |
|----------|---------|------|-----|
| jQuery | 3.7.1 | `code.jquery.com` | `sha256-…` |
| RequireJS | 2.3.7 | `cdnjs.cloudflare.com` | `sha512-…` |
| Bootstrap CSS | 5.3.3 | `cdn.jsdelivr.net` | `sha512-…` |

All three are version-pinned and integrity-pinned. **Any new CDN include must be both.** An
unversioned URL cannot carry a valid SRI hash, because its content changes underneath the pin.

### 12.3 SFMC Package Installation

1. Create an Installed Package in SFMC Setup.
2. Add a Journey Builder Activity component.
3. Set the endpoint URL to the activity's version folder.
4. Record the generated **JWT signing secret** and store it against the business unit in the
   middleware; the execute endpoints cannot verify requests without it.
5. Deploy to the appropriate business units.

Because Journey Builder caches `config.json`, a change to the descriptor generally requires a new
version folder and an endpoint URL update on the Installed Package.

### 12.4 Preventing Public Access

The activity hosts must serve the application files, but the repository also contains internal
material — this document, agent instruction files, and superseded version folders — that must not be
reachable.

#### The root cause: document root

Pointing the document root at the repository root is what exposes everything else. A non-production
descriptor served from the production host is readable by anyone, and those descriptors carry their
own environment's middleware hostname in `arguments.execute.url` — so the effort spent keeping
non-production hosts out of this document is undone by a single reachable
`dev/test/<activity>/<version>/config.json`.

**Preferred fix:** serve only the active production version folders plus `images/`, either by
pointing the root at a deploy directory that contains just those, or by denying the other trees:

```nginx
location ^~ /dev/ { return 404; }
location ^~ /mc/  { return 404; }
```

The rules below are defence in depth and should be applied regardless.

Apply to the server blocks on **both** the production and sandbox hosts:

```nginx
# Internal documentation and agent instruction files
location ~* \.(md|yml|yaml)$ { return 404; }

# Dotfiles: .gitignore, .env, .claude/, .sfdx
location ~ /\.              { return 404; }

autoindex off;
```

`config.json` must remain reachable — Journey Builder fetches it server-side — which is why the deny
rule enumerates `.md`/`.yml` rather than blanket-denying data files.

Verify after applying:

```bash
curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/README.md
curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/CLAUDE.md
curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/.gitignore
# all three must return 404

curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/
curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/dev/test/optin/v4/config.json
# must NOT return 200 — a directory index or a non-production descriptor means
# the document root still exposes the whole repository

curl -o /dev/null -w '%{http_code}\n' https://<activity-host>/prod/optin/v1/config.json
# must return 200
```

Serving only the active version folders, rather than the repository root, is a stronger form of the
same control and is preferred where the deployment process allows it.

---

## 13. Known Deviations from Salesforce Specification

Recorded for awareness. None currently causes a functional failure; none has been changed, because
editing a deployed descriptor requires a new version folder and re-registration of the Installed
Package.

| # | Deviation | Impact |
|---|-----------|--------|
| 1 | `configurationArguments.applicationExtensionKey` is absent from all descriptors, though Salesforce documents it as required | Activities function without it. Worth adding at the next version bump rather than as a standalone change |
| 2 | No `unpublish` lifecycle action is defined | Optional. The middleware would have no cleanup to perform on unpublish |
| 3 | `useJwt` is set only on `arguments.execute`, not on the `configurationArguments` actions. Salesforce's own example sets it on `execute`, `save`, `validate`, and `publish` | **Low priority as things stand.** Those four routes are no-ops that return `204` without reading the body or touching state, so an unauthenticated caller gains nothing. Worth fixing at the same time as, and only if, those handlers are given real behaviour — see [5.4](#54-journey-lifecycle-endpoints) |
| 4 | Icon path is absolute in the opt-in descriptor but relative (`images/sms.png`) in the plain and template descriptors | Cosmetic inconsistency. Both resolve correctly today |
| 5 | The code depends on three undocumented Postmonger payload fields | See [9.3](#93-undocumented-payload-fields-this-code-depends-on) |

---

## 14. Appendix

### 14.1 Glossary

| Term | Definition |
|------|------------|
| **SFMC** | Salesforce Marketing Cloud |
| **SBT** | Solutions By Text (SMS service provider) |
| **Journey Builder** | SFMC campaign orchestration tool |
| **Custom Activity** | Third-party integration step in a Journey Builder journey |
| **Postmonger** | SFMC's JavaScript library for iFrame-to-parent custom activity communication |
| **Installed Package** | SFMC construct registering the app and issuing its JWT signing secret |
| **MID** | Member ID — the identifier of an SFMC business unit |
| **TSSD** | Tenant-specific subdomain of an SFMC instance |
| **fuel2token** | Short-lived SFMC OAuth token passed to the iFrame via Postmonger |
| **Group** | SBT access unit; required to authorize a send |
| **Template** | Prewritten SBT message containing `{variable}` placeholders |
| **SmartURL** | Trackable shortened URL created by the SBT platform |
| **MMS** | Multimedia Messaging Service (SMS with media) |
| **inArguments** | Data sent from SFMC to the middleware at execution |
| **outArguments** | Data returned from the middleware to SFMC |
| **Data Extension** | SFMC database table for storing contact data |
| **Event Definition** | Schema describing a journey's entry event data |

### 14.2 Complete Saved Activity Payload — Subscriber Opt-In

The object passed to `connection.trigger("updateActivity", payload)`:

```json
{
  "name": "Opt in Subscriber for Marketing Group",
  "arguments": {
    "execute": {
      "inArguments": [
        {
          "subscriberKey": "{{Contact.Key}}",
          "Phone": "{{Event.APIEvent-123.Phone}}",
          "Email": "{{Event.APIEvent-123.Email}}",
          "Id": "{{Event.APIEvent-123.Id}}",
          "tssd": "<sfmc subdomain>",
          "mid": "<sfmc mid>",
          "groupId": "<sbt_group_id>",
          "resendVerification": false,
          "useDefaultOptInMethod": true,
          "optInType": null
        }
      ]
    }
  },
  "metaData": { "isConfigured": true }
}
```

Note that `save()` replaces the entire `inArguments` array with this single flattened object; the
seven separate entries declared in `config.json` are defaults only.

### 14.3 Reference Links

| Topic | URL |
|-------|-----|
| Custom Activity `config.json` | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/custom-activity-config.html |
| Postmonger Events Reference | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/using-postmonger.html |
| Build Custom Activities and Events | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/creating-activities.html |
| Encode Custom Activities Using JWT | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/encode-custom-activities-using-jwt.html |
| Encode with the JWT Signing Secret (app signature) | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/encode-custom-activities-using-jwt-app-signature.html |
| Secure a Custom Activity Using OAuth 2.0 | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/secure-custom-activity-using-oauth.html |
| Decode the JWT | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/decode-jwt.html |
| Encoded JWT structure | https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/encoded-jwt.html |

---

## Document Control

| Version | Date | Changes |
|---------|------|---------|
| 1.0 | December 2024 | Initial documentation |
| 2.0 | August 2026 | Corrected against the deployed codebase: activity inventory, endpoint paths, environment hosts, versions, and dependency list. Added the official Salesforce `config.json`, Postmonger, JWT-encoding, and OAuth references; the undocumented-dependency register (9.3); an accurate security model including the unsigned-lifecycle-endpoint finding (13, item 3); and the public-access controls in 12.4. Removed non-production hostnames and the consolidated endpoint map |

---

**End of Document**
