# Syncing Product Orgs from a CDP

> Source: https://docs.trailspark.ai/docs/cdp-product-org-setup

## Overview

Keep product org data current in TrailSpark by sending webhook calls from your CDP whenever org-level attributes change — plan tier, user count, MRR, trial status, or feature adoption. This is a lightweight alternative to building a dedicated ETL integration and works with any platform that can fire HTTP webhooks.

This guide covers setup for [Segment](#segment), [Rudderstack](#rudderstack), and [Hightouch](#hightouch). For the full endpoint reference and payload template configuration, see [Product Org Updates](https://docs.trailspark.ai/docs/product-org-updates).

## Prerequisites

1. Create an API key at **Settings** > **API Keys** with endpoint type **Product Org Updates**
2. Configure the **Payload Template** to map fields from your CDP's payload format to TrailSpark fields
3. Copy the API key — it is only displayed once

Your webhook endpoint:

```
POST https://{subdomain}.trailspark.ai/api/product-orgs/webhook/{apiKey}
```

Replace `{subdomain}` with your organization's subdomain and `{apiKey}` with the key you created.

## Segment

Segment's `group` call is designed for org-level data. Use the **Webhooks (Actions)** destination to forward group calls to TrailSpark's product org endpoint.

> [!NOTE]
> The classic Webhooks destination is in maintenance mode. Use **Webhooks (Actions)** for new setups.

### Adding the Destination

1. In Segment, go to **Connections** > **Catalog** > **Destinations**
2. Search for **Webhooks (Actions)** and select it
3. Choose the source that sends your group calls
4. Name the destination (e.g., "TrailSpark Product Orgs")
5. Enable the destination

### Creating the Mapping

1. Go to the **Mappings** tab and click **+ New Mapping**
2. Select the **Send** action
3. Set the trigger condition: **Event type is group** — this filters out track, identify, and page calls so only org-level data reaches this endpoint
4. Configure the request:

| Setting | Value |
|---------|-------|
| **URL** | `https://{subdomain}.trailspark.ai/api/product-orgs/webhook/{apiKey}` |
| **Method** | `POST` |
| **Content-Type** | `application/json` |
| **Headers** | Add `X-Api-Secret: {secret}` if your API key has a secret |

5. Select **Send the full event** as the body (TrailSpark's payload template handles field extraction)
6. Save and enable the mapping

### Instrumenting Group Calls

Fire a `group` call in your product when scoring-relevant attributes change. Do **not** call `group` on every request or page load — only when a value TrailSpark cares about actually changes.

Good trigger points:

- Subscription webhook handler (plan change, trial start/end)
- User invite or removal endpoint (user count change)
- Billing service callback (MRR change)
- Feature flag toggle

```javascript
// Example: call group after a plan change
analytics.group("ws_abc123", {
  name: "Acme Corp Workspace",
  plan: "pro_annual",
  plan_name: "Pro Annual",
  employees: 47,
  mrr: 49900,
  trial_status: "converted"
});
```

> [!TIP]
> If your backend doesn't have convenient hooks for every field change, a common pattern is a nightly batch job that compares current values to a snapshot and fires `group` only for orgs where scoring-relevant fields differ.

Segment delivers this as:

```json
{
  "type": "group",
  "groupId": "ws_abc123",
  "traits": {
    "name": "Acme Corp Workspace",
    "plan": "pro_annual",
    "plan_name": "Pro Annual",
    "employees": 47,
    "mrr": 49900,
    "trial_status": "converted"
  },
  "userId": "user_789",
  "timestamp": "2026-01-15T10:30:00.000Z",
  "messageId": "seg-msg-abc123"
}
```

### Payload Template Mapping

Configure the payload template on your API key to match Segment's group call structure:

| TrailSpark Field | Payload Path |
|------------------|--------------|
| **Product Org ID** | `groupId` |
| **Name** | `traits.name` |
| **Plan ID** | `traits.plan` |
| **Plan Name** | `traits.plan_name` |
| **User Count** | `traits.employees` |
| **MRR** | `traits.mrr` |
| **Trial Status** | `traits.trial_status` |

## Rudderstack

Rudderstack follows the same event specification as Segment. Use the **Webhook** destination with a transformation to filter for group calls.

### Adding the Destination

1. In the Rudderstack dashboard, go to **Destinations** > **New Destination**
2. Select **Webhook** (or **HTTP Webhook** for the no-code option)
3. Enter the webhook URL: `https://{subdomain}.trailspark.ai/api/product-orgs/webhook/{apiKey}`
4. Set request format to **JSON**
5. Add headers if your API key has a secret: `X-Api-Secret: {secret}`

### Adding a Transformation

Rudderstack's webhook destination sends all event types by default. Add a transformation to filter for group calls only:

1. Go to **Transformations** > **New Transformation**
2. Add the following code:

```javascript
export function transformEvent(event, metadata) {
  if (event.type !== 'group') {
    return null;
  }
  return event;
}
```

3. Attach the transformation to the webhook destination connection

### Instrumenting Group Calls

The same guidance applies as Segment — only fire `group` when scoring-relevant fields change, not on every request.

```javascript
rudderanalytics.group("ws_abc123", {
  name: "Acme Corp Workspace",
  plan: "pro_annual",
  plan_name: "Pro Annual",
  employees: 47,
  mrr: 49900,
  trial_status: "converted"
});
```

Rudderstack's group payload uses the same structure as Segment (`groupId`, `traits.*`), so the same payload template mapping works for both platforms. See the [Segment mapping table](#payload-template-mapping) above.

## Hightouch

Hightouch is a reverse ETL tool that syncs data from your data warehouse to destinations. Instead of instrumenting group calls in your product code, you write a SQL model that selects org-level data and Hightouch sends it to TrailSpark on a schedule.

### Creating the HTTP Request Destination

1. In Hightouch, go to **Destinations** > **Add Destination**
2. Select **HTTP Request**
3. Set the **Base URL**: `https://{subdomain}.trailspark.ai`
4. Add headers if your API key has a secret: `X-Api-Secret: {secret}`

### Creating the Source Model

Create a SQL model that selects the org-level data you want to sync.

> [!WARNING]
> **Only include columns that matter for scoring.** Hightouch CDC compares the full model output between syncs — if *any* column changes, that row is sent as a webhook call. Including volatile columns like `last_active_at` or `updated_at` causes every org to be sent on every sync, defeating the purpose of CDC.

1. Go to **Models** > **Add Model**
2. Select your data warehouse source and choose **SQL editor**
3. Write a query that returns one row per product org, including **only scoring-relevant fields**:

```sql
SELECT
  workspace_id,
  workspace_name,
  plan_id,
  plan_name,
  user_count,
  mrr_cents AS mrr,
  trial_status
FROM product.workspaces
```

4. Click **Preview** to verify results
5. Set the **Primary key** to `workspace_id`
6. Save the model

With this model, Hightouch only sends a webhook when `plan_id`, `plan_name`, `user_count`, `mrr_cents`, or `trial_status` actually changes for an org. Orgs with no changes are skipped entirely.

### Creating the Sync

1. Go to **Syncs** > **Add Sync**
2. Select your SQL model as the source and the HTTP Request destination
3. Configure request triggers: **Rows added** and **Rows changed**
4. Set the endpoint path: `/api/product-orgs/webhook/{apiKey}`
5. Set method to **POST**
6. In the **JSON editor**, map columns using Liquid template syntax:

```json
{
  "workspace_id": "{{row.workspace_id}}",
  "workspace_name": "{{row.workspace_name}}",
  "plan_id": "{{row.plan_id}}",
  "plan_name": "{{row.plan_name}}",
  "user_count": {{row.user_count}},
  "mrr": {{row.mrr}},
  "trial_status": "{{row.trial_status}}"
}
```

7. Set the sync schedule — hourly or daily is typical
8. Save and enable

### Payload Template Mapping

Since the Hightouch payload uses a flat structure, the payload template paths are just the top-level field names:

| TrailSpark Field | Payload Path |
|------------------|--------------|
| **Product Org ID** | `workspace_id` |
| **Name** | `workspace_name` |
| **Plan ID** | `plan_id` |
| **Plan Name** | `plan_name` |
| **User Count** | `user_count` |
| **MRR** | `mrr` |
| **Trial Status** | `trial_status` |

Hightouch's change data capture (CDC) automatically compares model output between syncs. Only orgs where at least one column value changed will trigger a webhook call — unchanged orgs are skipped entirely.

## When to Send Updates

Send a product org update only when a scoring-relevant attribute changes. Each webhook call is an API request — sending unchanged data wastes volume and provides no scoring value.

**Attributes worth tracking:**

- **Plan changes** — upgrades, downgrades, trial start or end
- **User count** — new users added or removed from the workspace
- **MRR changes** — billing adjustments, add-ons, renewals
- **Feature flag toggles** — SSO enabled, API access granted
- **Trial status transitions** — active → expired → converted

**Attributes to avoid** (change too frequently, low scoring value):

- `last_active_at` / `last_login_at` — updates on every session
- `updated_at` — updates on any row change, including non-scoring fields
- Request counts or page views — better captured as behavioral signals via the signal staging endpoint

### Platform-specific guidance

**Segment and Rudderstack**: Only fire `group` calls from code paths where scoring-relevant fields are being modified — subscription handlers, billing callbacks, user management endpoints. Do not call `group` on every page load or API request.

**Hightouch**: Only include scoring-relevant columns in your SQL model. CDC compares the full model output between syncs, so any column change triggers a send. If your model includes `last_active_at`, every active org gets sent on every sync. Remove volatile columns to let CDC do its job.

## Testing

Send a test payload with cURL to verify your endpoint and payload template:

```bash
curl -X POST \
  https://{subdomain}.trailspark.ai/api/product-orgs/webhook/{apiKey} \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "test_ws_001",
    "workspace_name": "Test Workspace",
    "plan_id": "pro",
    "plan_name": "Pro",
    "user_count": 10,
    "mrr": 9900,
    "trial_status": "active"
  }'
```

A successful response returns:

```json
{
  "success": true,
  "productOrgId": "test_ws_001",
  "targetOrgId": 123,
  "isNewOrg": true
}
```

Then check **Settings** > **Org Management** in TrailSpark to confirm the product org appeared with the correct fields.

## Troubleshooting

**HTTP 403** — The API key was not created with the Product Org Updates endpoint type. Create a new key with the correct type at **Settings** > **API Keys**.

**HTTP 400: missing productOrgId** — The payload template's Product Org ID path does not match a field in the incoming payload. Verify the dot-notation path (e.g., `groupId` for Segment, `workspace_id` for flat payloads).

**Fields not appearing** — Check that the optional field paths in the payload template match your actual payload structure. Paths are case-sensitive and use dot notation for nested fields (e.g., `traits.plan`).

**Hightouch sync failing** — Verify the primary key column contains unique, non-null values. Check that the SQL model returns results when previewed. Confirm the base URL and endpoint path are correct.

**Duplicate product orgs** — TrailSpark upserts by Product Org ID. If you see duplicates, verify the Product Org ID value is consistent across all payloads (case-sensitive exact match).

## Next Steps

- [Product Org Updates](https://docs.trailspark.ai/docs/product-org-updates) — full endpoint and payload template reference
- [API Keys](https://docs.trailspark.ai/docs/api-keys) — manage keys and configure secrets
- [Creating Signal Mapping](https://docs.trailspark.ai/docs/creating-signal-mapping) — set up rules to score incoming signals