Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sample-apps/oauth-crm-sync/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
oauth.local.json
.oauth.key
iparams.local.json
logs/
dist/
*.zip
73 changes: 73 additions & 0 deletions sample-apps/oauth-crm-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# oauth-crm-sync

Sample Chargebee custom app demonstrating OAuth 2.0 integration.

Listens to `customer_created` and `subscription_created` events, then syncs the data to HubSpot CRM using an OAuth-authorized access token.

## Setup

### 1. Create a HubSpot OAuth app

1. Go to [HubSpot Developer Portal](https://developers.hubspot.com/) → Apps → Create app
2. Under **Auth** → **OAuth**, note your **Client ID** and **Client Secret**
3. Add redirect URI: `http://localhost:10101/oauth/callback` (or the port you run the CLI on)
4. Add scopes: `crm.objects.contacts.read crm.objects.contacts.write`

### 2. Configure credentials

Edit `oauth_configs.json` and replace the placeholder values:

```json
{
"connectors": {
"hubspot": {
"client_id": "<YOUR_HUBSPOT_CLIENT_ID>",
"client_secret": "<YOUR_HUBSPOT_CLIENT_SECRET>",
...
}
}
}
```

### 3. Run the tester UI

```bash
cb-apps run --dir .
```

Open `http://localhost:10101` in your browser.

### 4. Authorize via OAuth

1. Click the **OAuth** tab in the tester UI
2. Click **Connect** next to `hubspot`
3. Complete the HubSpot authorization flow in the popup
4. The tab shows **Authorized** once the token is saved

### 5. Test an event

1. Select `customer_created` from the event dropdown
2. Click **Invoke** — the handler creates or updates a HubSpot contact
3. Select `subscription_created` and click **Invoke** — a note is added in HubSpot

## How OAuth tokens work

- Tokens are encrypted with AES-256-GCM and stored in `oauth.local.json` (gitignored)
- The encryption key lives in `.oauth.key` (also gitignored, `0600` permissions)
- On every invocation, the CLI decrypts the token and injects it as:
```js
payload.oauth_token.hubspot.access_token // Bearer token
payload.oauth_token.hubspot.token_type // "Bearer"
```
- Only `access_token` and `token_type` are exposed to handler code; `refresh_token` stays encrypted on disk

## Files

| File | Purpose |
|------|---------|
| `manifest.json` | App metadata and event-to-handler mapping |
| `oauth_configs.json` | OAuth connector credentials (fill in your client_id/secret) |
| `handler/handler.js` | Event handler code |
| `test_data/` | Sample payloads for local testing |
| `types/types.d.ts` | TypeScript type hints for the handler payload |
| `.gitignore` | Excludes secrets and generated files |
137 changes: 137 additions & 0 deletions sample-apps/oauth-crm-sync/handler/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* oauth-crm-sync — sample app demonstrating OAuth 2.0 token usage.
*
* How OAuth tokens reach this handler:
* 1. Add your OAuth credentials to oauth_configs.json.
* 2. Open the tester UI (cb-apps run), click the OAuth tab, and click Connect.
* 3. After authorizing, tokens are encrypted and saved locally.
* 4. On every invocation, the CLI decrypts the token and injects it as
* payload.oauth_token["hubspot"].access_token (Bearer token ready to use).
*
* Available in payload.oauth_token only when:
* - oauth_configs.json exists in the app directory, AND
* - the connector has been authorized via the tester UI
*/

'use strict';

const HUBSPOT_API = 'https://api.hubapi.com';

module.exports = {
/**
* Creates or updates a HubSpot contact when a Chargebee customer is created.
* @param {import('../types/types.d.ts').HandlerPayload} payload
*/
onCustomerCreated: async function (payload) {
const token = getToken(payload, 'hubspot');
const customer = payload.event.content.customer;

const [firstName, ...rest] = (customer.first_name || '').split(' ');
const lastName = customer.last_name || rest.join(' ') || '';

const contact = {
properties: {
email: customer.email,
firstname: firstName,
lastname: lastName,
phone: customer.phone || '',
company: customer.company || '',
chargebee_customer_id: customer.id,
},
};

const existing = await hubspotGet(token, `/crm/v3/objects/contacts/${customer.email}?idProperty=email`);

if (existing.id) {
await hubspotPatch(token, `/crm/v3/objects/contacts/${existing.id}`, contact);
console.log(`[CRM Sync] Updated HubSpot contact ${existing.id} for customer ${customer.id}`);
} else {
const created = await hubspotPost(token, '/crm/v3/objects/contacts', contact);
console.log(`[CRM Sync] Created HubSpot contact ${created.id} for customer ${customer.id}`);
}
},

/**
* Logs a HubSpot note when a Chargebee subscription is created.
* @param {import('../types/types.d.ts').HandlerPayload} payload
*/
onSubscriptionCreated: async function (payload) {
const token = getToken(payload, 'hubspot');
const subscription = payload.event.content.subscription;
const customer = payload.event.content.customer;

const note = {
properties: {
hs_note_body: [
`Chargebee subscription created`,
`Subscription ID: ${subscription.id}`,
`Plan: ${subscription.subscription_items?.[0]?.item_price_id ?? 'N/A'}`,
`Status: ${subscription.status}`,
`Customer: ${customer?.email ?? subscription.customer_id}`,
].join('\n'),
hs_timestamp: new Date().toISOString(),
},
};

const created = await hubspotPost(token, '/crm/v3/objects/notes', note);
console.log(`[CRM Sync] Created HubSpot note ${created.id} for subscription ${subscription.id}`);
},
};

// ── Helpers ──────────────────────────────────────────────────────────────────

/**
* Returns the Bearer token for a connector.
* Throws a descriptive error when the connector hasn't been authorized yet,
* so the developer sees a clear message in the tester UI logs.
*/
function getToken(payload, connectorName) {
const token = payload.oauth_token?.[connectorName]?.access_token;
if (!token) {
throw new Error(
`OAuth token for '${connectorName}' is not available. ` +
`Open the tester UI, go to the OAuth tab, and click Connect to authorize.`
);
}
return token;
}

async function hubspotGet(token, path) {
const res = await fetch(`${HUBSPOT_API}${path}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok && res.status !== 404) {
throw new Error(`HubSpot GET ${path} failed: ${res.status} ${await res.text()}`);
}
return res.status === 404 ? {} : res.json();
}

async function hubspotPost(token, path, body) {
const res = await fetch(`${HUBSPOT_API}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HubSpot POST ${path} failed: ${res.status} ${await res.text()}`);
}
return res.json();
}

async function hubspotPatch(token, path, body) {
const res = await fetch(`${HUBSPOT_API}${path}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HubSpot PATCH ${path} failed: ${res.status} ${await res.text()}`);
}
return res.json();
}
14 changes: 14 additions & 0 deletions sample-apps/oauth-crm-sync/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "oauth-crm-sync",
"version": "1.0.0",
"description": "Sample app: syncs Chargebee customer and subscription events to a CRM via OAuth 2.0.",
"events": {
"customer_created": {
"handler": "onCustomerCreated"
},
"subscription_created": {
"handler": "onSubscriptionCreated"
}
},
"dependencies": {}
}
13 changes: 13 additions & 0 deletions sample-apps/oauth-crm-sync/oauth_configs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"connectors": {
"hubspot": {
"client_id": "<YOUR_HUBSPOT_CLIENT_ID>",
"client_secret": "<YOUR_HUBSPOT_CLIENT_SECRET>",
"authorize_url": "https://app.hubspot.com/oauth/authorize",
"token_url": "https://api.hubapi.com/oauth/v1/token",
"options": {
"scope": "crm.objects.contacts.read crm.objects.contacts.write"
}
}
}
}
23 changes: 23 additions & 0 deletions sample-apps/oauth-crm-sync/test_data/customer_created.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"id": "evt_sample_cust_001",
"api_version": "v2",
"object": "event",
"occurred_at": 1700000000,
"source": "api",
"webhook_status": "success",
"webhooks": [],
"event_type": "customer_created",
"content": {
"customer": {
"id": "cust_sample_001",
"email": "alex.sample@example.com",
"first_name": "Alex",
"last_name": "Sample",
"phone": "+1-555-0100",
"company": "Example Corp",
"created_at": 1700000000,
"updated_at": 1700000000,
"object": "customer"
}
}
}
36 changes: 36 additions & 0 deletions sample-apps/oauth-crm-sync/test_data/subscription_created.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"id": "evt_sample_sub_001",
"api_version": "v2",
"object": "event",
"occurred_at": 1700000060,
"source": "api",
"webhook_status": "success",
"webhooks": [],
"event_type": "subscription_created",
"content": {
"subscription": {
"id": "sub_sample_001",
"customer_id": "cust_sample_001",
"status": "active",
"created_at": 1700000060,
"updated_at": 1700000060,
"subscription_items": [
{
"item_price_id": "starter-monthly-usd",
"quantity": 1,
"unit_price": 4900,
"amount": 4900,
"object": "subscription_item"
}
],
"object": "subscription"
},
"customer": {
"id": "cust_sample_001",
"email": "alex.sample@example.com",
"first_name": "Alex",
"last_name": "Sample",
"object": "customer"
}
}
}
78 changes: 78 additions & 0 deletions sample-apps/oauth-crm-sync/types/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Type definitions for Chargebee Apps applications
* This file provides TypeScript types for common structures used in Chargebee apps
*/

/**
* Event record structure for Chargebee webhook events
* This represents the actual structure of Chargebee webhook events
* Note: Event types are dynamically generated by Chargebee
* Examples include: 'customer_created', 'subscription_created', 'subscription_renewed', etc.
*/
export interface EventRecord {
/** API version */
api_version: string;
/** Event content containing the actual data */
content: Record<string, any>;
/** Type of the event (e.g., 'customer_created', 'subscription_created') */
event_type: string;
/** Unique identifier for the event */
id: string;
/** Object type */
object: string;
/** Timestamp when the event occurred */
occurred_at: number;
/** Source of the event */
source: string;
/** Webhook status */
webhook_status: string;
/** Array of webhook configurations */
webhooks: Array<{
id: string;
object: string;
webhook_status: string;
}>;
}

/**
* OAuth access token exposed to handler code.
* Only access_token and token_type are available — refresh_token and
* client credentials are never injected into the handler.
*/
export interface OAuthAccessToken {
/** Bearer access token for authenticating API requests */
access_token: string;
/** Token type, typically "Bearer" */
token_type: string;
}

/**
* Single argument passed to every event handler.
* Use payload.event for the webhook event and payload.oauth_token.<connector> for OAuth access tokens.
*/
export interface HandlerPayload {
/** The webhook event record */
event: EventRecord;
/** OAuth access tokens keyed by connector name (from oauth_configs.json) */
oauth_token?: Record<string, OAuthAccessToken>;
}

/**
* Optional return value from a handler function.
*
* Return this when the handler wants to signal an application-level error
* WITHOUT triggering a platform retry (e.g. invalid input, business rule
* violation). Throwing an exception causes the platform to retry the event;
* returning a HandlerResult with statusCode >= 400 does not.
*
* If the handler returns nothing (or undefined), statusCode defaults to 200.
*/
export interface HandlerResult {
/**
* HTTP status code (100–599). Defaults to 200 if omitted.
* Return 4xx to signal a non-retryable application error.
*/
statusCode?: number;
/** Optional response body (e.g. a JSON-encoded error message). */
body?: string;
}
Loading