Skip to content
Open
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_config.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_config.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_config.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_config.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_config.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"
}
}
}
10 changes: 10 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,10 @@
interface OAuthAccessToken {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cb-jananivijayan this looks like a manually created types.d.ts, for consistency can we have the same types.d.ts as the oauth template as created by the chargebee-apps create command.

access_token: string;
token_type: string;
}

interface HandlerPayload {
event: import('@chargebee/chargebee-apps-shared').EventRecord;
iparams?: Record<string, any>;
oauth_token?: Record<string, OAuthAccessToken>;
}
Loading