# HubSpot Webhook Integration Guide

**Version:** 2.0 (2026-04-12)
**Target:** HubSpot admins connecting to Social Hub for multi-platform post publishing
**Delta from v1.0:** Custom Workflow Action envelope, flat-string field coercion, callback completion

---

## 1. Architecture Overview

```
HubSpot Custom Workflow Action (appId 36566175)
         │
         ▼ (POST webhook — Gate 0 envelope)
Social Hub: /api/v1/webhooks/hubspot
         │
         ▼ (signature verification — v2 + v3 both sent)
Field Coercion Layer (wrap flat strings → arrays)
         │
         ▼
Translation Layer: lib/hubspot-translation.ts
         │
         ▼ (region resolution, track mapping)
Post Creation API: /api/v1/posts
         │
         ▼ (callback completion POST to HubSpot)
Callback: /automation/v4/actions/callbacks/complete
         │
         ▼ (scheduler)
Platform Publishers
    ├── Twitter/X (lib/networks/twitter.ts)
    ├── LinkedIn  (lib/networks/linkedin.ts)
    ├── Facebook  (lib/networks/facebook.ts)
    └── Instagram (lib/networks/instagram.ts)
```

---

## 2. HubSpot Setup — Custom Workflow Action via PLG GTM Webhooks

> **v2.0 change:** This section is completely rewritten. The integration uses a **Custom Workflow Action** via the PLG GTM Webhooks app (appId `36566175`), NOT a generic webhook action.

### Step 1: Install the PLG GTM Webhooks App

1. In HubSpot, go to **Settings → Integrations → Connected Apps**
2. Find and install the **PLG GTM Webhooks** app (appId `36566175`)
3. This app provides a custom workflow action that sends structured payloads

### Step 2: Create a Workflow with the Custom Action

1. Go to **Automation → Workflows**
2. Create a new workflow (Contact-based, Deal-based, or Custom-object-based)
3. Add the **PLG GTM Webhooks** action (appears under "Connected apps" in the action picker)
4. Configure the action's input fields (see Section 3)

### Step 3: Configure the Webhook URL

**URL:** `https://your-app.example.com/api/v1/webhooks/hubspot`
**Method:** `POST`
**Content Type:** `application/json`

### Step 4: Configure Signature Verification

HubSpot sends **both** `x-hubspot-signature` (v2) and `x-hubspot-signature-v3` headers on every request. Social Hub verifies v3 first (HMAC-SHA256 with timestamp for replay protection), falling back to v2.

**HubSpot Setting:** Set your **client secret** in the app configuration.
**Social Hub:** Set `HUBSPOT_WEBHOOK_SECRET` env var to the same value.

---

## 3. Payload Format — Gate 0 Envelope

> **v2.0 change:** HubSpot custom actions use a Gate 0 envelope with `callbackId`, `fields`, `inputFields`, and `typedInputs`. NOT the old `{ eventId, properties }` format.

### Envelope Structure

```json
{
  "callbackId": "ap-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
  "origin": { ... },
  "object": { "objectId": 123456, "objectType": "CONTACT" },
  "inputFields": {
    "content": "Excited to announce TiDB 8.0! #TiDB",
    "networks": "TWITTER",
    "track": "DEVELOPER",
    "region": "GLOBAL",
    "mediaUrls": "https://example.com/image.jpg",
    "tags": "tidb,database,launch",
    "campaignName": "TiDB 8.0 Launch",
    "priority": "NORMAL"
  },
  "typedInputs": { ... }
}
```

### Field Table

> **v2.0 change:** HubSpot custom actions can only send **flat strings**. Social Hub must coerce 3 fields from strings to arrays.

| Field | HubSpot Sends | Social Hub Coerces To | Required | Description |
|-------|---------------|----------------------|----------|-------------|
| `content` | `"post text"` | Pass through as-is | Yes* | The actual post text |
| `networks` | `"TWITTER"` | `["TWITTER"]` (wrap in array) | No | Target platforms. Default: `["TWITTER"]` |
| `region` | `"APAC"` | Pass through as-is | No | `APAC`, `AMERICAS`, `EMEA`, `GLOBAL` |
| `track` | `"DEVELOPER"` | Pass through as-is | No | Content track |
| `priority` | `"NORMAL"` | Pass through as-is | No | `NORMAL`, `URGENT`, `EMERGENCY` |
| `campaignName` | `"Campaign X"` | Pass through as-is | No | For campaign association |
| `mediaUrls` | `"https://img.png"` | `["https://img.png"]` (wrap in array) | No | Media attachment URL |
| `tags` | `"tag1,tag2"` | `["tag1", "tag2"]` (split on comma) | No | Post tags |
| `asset_id` | `"12345"` | Pass through as-is | No | HubSpot asset reference |

*\*At least one content source must be present (`content`, `hs_notes`, or `hs_name`).*

### Coercion Rules (implemented in `lib/webhooks/hubspot.ts`)

```typescript
// networks: string → string[]
if (typeof fields.networks === "string") {
  fields.networks = fields.networks.includes(",")
    ? fields.networks.split(",").map(s => s.trim())
    : [fields.networks];
}

// mediaUrls: string → string[]
if (typeof fields.mediaUrls === "string") {
  fields.mediaUrls = fields.mediaUrls.includes(",")
    ? fields.mediaUrls.split(",").map(s => s.trim())
    : [fields.mediaUrls];
}

// tags: string → string[]
if (typeof fields.tags === "string") {
  fields.tags = fields.tags.split(",").map(s => s.trim());
}
```

---

## 4. Payload Examples

> **v2.0 change:** All examples updated to Gate 0 envelope format with flat string fields.

### Basic Post (Single Platform)

```json
{
  "callbackId": "ap-12345-abcd-6789",
  "inputFields": {
    "content": "Excited to announce TiDB 8.0! Faster, more reliable, open-source distributed SQL. #TiDB #Database",
    "networks": "TWITTER",
    "track": "DEVELOPER"
  }
}
```

### Multi-Platform Post

```json
{
  "callbackId": "ap-12346-abcd-6789",
  "inputFields": {
    "content": "TiDB Cloud now supports multi-region deployments. Deploy globally, manage locally.",
    "networks": "TWITTER,LINKEDIN",
    "track": "PRODUCT",
    "region": "GLOBAL",
    "priority": "URGENT"
  }
}
```

### Post with Media

```json
{
  "callbackId": "ap-12347-abcd-6789",
  "inputFields": {
    "content": "Check out our latest webinar recording!",
    "networks": "TWITTER",
    "track": "EVENT",
    "mediaUrls": "https://example.com/webinar-thumbnail.jpg",
    "tags": "webinar,tidb-cloud"
  }
}
```

### Region-Specific Post

```json
{
  "callbackId": "ap-12348-abcd-6789",
  "inputFields": {
    "content": "TiDB Cloud is now available in APAC regions!",
    "networks": "TWITTER,LINKEDIN",
    "track": "PRODUCT",
    "region": "APAC"
  }
}
```

---

## 5. Callback Completion (REQUIRED)

> **v2.0 new section.** Social Hub must complete the callback or the workflow action hangs forever in HubSpot.

After processing the webhook, Social Hub must POST to HubSpot's callback completion endpoint:

```
POST https://api.hubapi.com/automation/v4/actions/callbacks/complete
?hapikey=<HUBSPOT_DEVELOPER_API_KEY>
Content-Type: application/json

{
  "callbackId": "ap-12345-abcd-6789",
  "outputFields": {
    "postId": "clxyz123...",
    "status": "SCHEDULED",
    "hs_execution_state": "SUCCESS"
  }
}
```

### Failure callback:

```json
{
  "callbackId": "ap-12345-abcd-6789",
  "outputFields": {
    "hs_execution_state": "FAIL_CONTINUE",
    "error": "No ADMIN user found"
  }
}
```

### States:
| `hs_execution_state` | Meaning |
|----------------------|---------|
| `SUCCESS` | Action completed successfully |
| `FAIL_CONTINUE` | Action failed, workflow continues |
| `BLOCK` | Action failed, workflow pauses for retry |

---

## 6. API Endpoints

### Production Webhook
```
POST https://your-app.example.com/api/v1/webhooks/hubspot
```
- Requires valid signature (v2 or v3)
- Rate limited: 30 requests per 60 seconds per IP
- Returns: `{ "success": true, "processed": 1, "results": [...] }`
- Completes HubSpot callback automatically

### Test Endpoint (No Signature Required)
```
POST https://your-app.example.com/api/v1/webhooks/hubspot/test
```
- No signature verification (for testing only)
- Same payload format as production
- Returns same response structure

---

## 7. Authentication & Signatures

> **v2.0 change:** Both v2 and v3 signatures are sent on every request.

### Webhook → Social Hub

HubSpot sends **both** signature headers:
- `x-hubspot-signature` — v2: `SHA256(clientSecret + method + URI + body)`
- `x-hubspot-signature-v3` — v3: `Base64(HMAC-SHA256(clientSecret, method + URI + body + timestamp))`
- `x-hubspot-request-timestamp` — millisecond timestamp (for v3 replay protection)

Social Hub verifies **v3 first** (preferred, includes replay protection with 5-minute window), then falls back to v2.

### Social Hub → Platform APIs
- **Twitter:** OAuth 1.0a (per-user tokens stored encrypted)
- **LinkedIn:** OAuth 2.0 (per-user tokens stored encrypted)
- **Facebook:** OAuth 2.0 (per-user tokens stored encrypted)
- **Instagram:** OAuth 2.0 (per-user tokens stored encrypted)

---

## 8. Multi-Platform Publishing Flow

When a webhook creates a post with multiple networks (e.g., `"TWITTER,LINKEDIN"`):

1. **Field Coercion:** `"TWITTER,LINKEDIN"` → `["TWITTER", "LINKEDIN"]`
2. **Post Creation:** Single post record created with `networks: ["TWITTER", "LINKEDIN"]`
3. **Content Adaptation:** Content is adapted per platform:
   - **Twitter:** Truncated to 280 chars, URLs shortened to 23 chars
   - **LinkedIn:** Up to 3000 chars, hashtags moved to bottom
   - **Facebook:** Markdown stripped, links preserved
   - **Instagram:** Links stripped, hashtags extracted to first comment
4. **Scheduling:** Post is scheduled based on track's best posting times
5. **Publishing:** Each platform publishes independently via its API
6. **Callback:** HubSpot callback completed with post ID and status
7. **Status Tracking:** Each platform's publish status is tracked separately

---

## 9. Error Handling & Retry

### Response Codes
| Code | Meaning | Action |
|------|---------|--------|
| 200 | Success | Webhook processed, callback completed |
| 400 | Bad request | Invalid JSON or missing required fields |
| 401 | Unauthorized | Invalid signature (v2 and v3 both failed) |
| 429 | Rate limited | Too many requests (retry after 60s) |
| 500 | Server error | Processing failed (HubSpot should retry) |
| 503 | Service unavailable | Webhook endpoint not configured |

### Deduplication
- Duplicate `callbackId` values are automatically detected and skipped
- Returns: `{ "success": true, "processed": 0, "deduplicated": true }`

### Retry Behavior
- HubSpot retries on 500/503 errors automatically
- Social Hub logs all webhook attempts to `webhookLog` table
- Failed webhooks send `FAIL_CONTINUE` callback so workflow proceeds

---

## 10. Region Resolution

When `region` is not explicitly set, the translation layer resolves it from:

1. **Explicit:** `inputFields.region` field
2. **HubSpot Property:** `social_region` custom campaign property
3. **Audience Keywords:** Match keywords in `hs_audience` (e.g., "asia developers" → APAC)
4. **UTM Suffix:** Parse campaign name (e.g., "product-launch-apac" → APAC)
5. **Campaign Region:** Inherit from parent campaign's region
6. **Default:** `GLOBAL`

---

## 11. Required Environment Variables

| Variable | Purpose | Example |
|----------|---------|---------|
| `HUBSPOT_WEBHOOK_SECRET` | HMAC signature verification key | `your-secret-key-here` |
| `HUBSPOT_DEVELOPER_API_KEY` | Developer API key for callback completion (hapikey) | `9d558626-xxxx-xxxx` |
| `TWITTER_CONSUMER_KEY` | Twitter API key | `abc123...` |
| `TWITTER_CONSUMER_SECRET` | Twitter API secret | `def456...` |
| `LINKEDIN_CLIENT_ID` | LinkedIn OAuth client ID | `ghi789...` |
| `LINKEDIN_CLIENT_SECRET` | LinkedIn OAuth client secret | `jkl012...` |
| `ENCRYPTION_KEY` | OAuth token encryption (AES-256) | `32-byte-hex-key` |
| `DATABASE_URL` | TiDB/MySQL connection string | `mysql://...` |
| `NEXTAUTH_SECRET` | NextAuth session secret | `random-string` |
| `NEXTAUTH_URL` | Base URL for auth | `https://your-app.example.com` |

---

## 12. Troubleshooting

### "Invalid signature" error
- Both v2 and v3 signatures are verified — ensure `HUBSPOT_WEBHOOK_SECRET` matches the app's client secret
- Check that HubSpot is sending both `x-hubspot-signature` and `x-hubspot-signature-v3` headers
- v3 has a 5-minute replay window — check clock skew

### Workflow action stuck / hanging
- Social Hub must POST callback completion to `/automation/v4/actions/callbacks/complete`
- Check that `HUBSPOT_ACCESS_TOKEN` is set and valid
- Check logs for callback errors

### "No ADMIN user found" error
- The Social Hub requires at least one user with `role: "ADMIN"`
- Create an admin user via the settings page or database

### Post created but not published
- Check if the post was scheduled (not immediate publish)
- Verify platform OAuth tokens are connected (`/dashboard/settings`)
- Check scheduler status: `GET /api/v1/health`

### Fields arriving as strings instead of arrays
- This is expected — HubSpot custom actions send flat strings
- Social Hub coerces `networks`, `mediaUrls`, and `tags` automatically
- No action needed on the HubSpot side

---

## 13. Testing Your Integration

1. **Use the test endpoint first:**
   ```bash
   curl -X POST https://your-app.example.com/api/v1/webhooks/hubspot/test \
     -H "Content-Type: application/json" \
     -d '{"content":"Test post","networks":"TWITTER","track":"GENERAL"}'
   ```
   Note: send `networks` as a flat string (like HubSpot does) — Social Hub coerces it.

2. **Check the response:**
   ```json
   { "simulator": true, "success": true, "translation": {...}, "webhookResponse": {...} }
   ```

3. **Verify the post was created:**
   ```bash
   curl https://your-app.example.com/api/v1/posts?limit=1
   ```

---

## 14. Support

- **API Documentation:** `docs/` directory in the Social Hub repo
- **Admin Dashboard:** `https://your-app.example.com/dashboard`
- **Health Check:** `https://your-app.example.com/api/v1/health`
- **Test Endpoint:** `POST /api/v1/webhooks/hubspot/test` (no auth required)
