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
42 changes: 42 additions & 0 deletions apps/docs/content/docs/integrations/apollo.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,50 @@ import { ReferenceDetails } from "@/components/ui/reference-details"

{/* MANUAL-CONTENT-START:intro */}
Use [Apollo.io](https://apollo.io/) in Sim to search and enrich people and companies, manage CRM contacts and accounts, add contacts to sequences, and create follow-up tasks. Single-record and bulk enrichment operations are documented below.

Connect an Apollo account with OAuth, or save an API key as an Apollo credential. Existing API-key connections and workflows continue to work. OAuth requires an Apollo administrator or a user with permission to authorize third-party apps. Your Apollo plan, permissions, and credits still apply.

For self-hosting, register a public partner OAuth app in the [Apollo developer portal](https://developer.apollo.io/), configure `APOLLO_CLIENT_ID` and `APOLLO_CLIENT_SECRET`, and register `https://<your-domain>/api/auth/oauth2/callback/apollo` as a redirect URL. Enable the scopes listed below. API-key credentials remain available without an OAuth client.
{/* MANUAL-CONTENT-END */}

## OAuth scopes [#oauth-scopes]

Sim requests these scopes when someone connects an Apollo account. For a self-hosted deployment, register your own app with the provider using the settings below. See [Integrations and OAuth](/platform/self-hosting/integrations-oauth) for setup.

| Setting | Value |
| ------- | ----- |
| Redirect URI | `<NEXT_PUBLIC_APP_URL>/api/auth/oauth2/callback/apollo` |
| Environment variables | `APOLLO_CLIENT_ID`, `APOLLO_CLIENT_SECRET` |

| Scope | Description |
| ----- | ----------- |
| `read_user_profile` | View your Apollo profile |
| `mixed_people_api_search` | Search Apollo people |
| `people_match` | Enrich a person in Apollo |
| `people_bulk_match` | Enrich people in bulk in Apollo |
| `mixed_companies_search` | Search Apollo companies |
| `organizations_enrich` | Enrich an Apollo organization |
| `organizations_bulk_enrich` | Enrich organizations in bulk in Apollo |
| `contact_write` | Create Apollo contacts |
| `contact_update` | Update Apollo contacts |
| `contacts_search` | Search Apollo contacts |
| `contacts_bulk_create` | Create Apollo contacts in bulk |
| `contacts_bulk_update` | Update Apollo contacts in bulk |
| `account_write` | Create Apollo accounts |
| `account_update` | Update Apollo accounts |
| `accounts_search` | Search Apollo accounts |
| `account_bulk_create` | Create Apollo accounts in bulk |
| `account_stages_update` | Update Apollo account stages |
| `opportunity_write` | Create Apollo deals |
| `opportunities_list` | List Apollo deals |
| `opportunity_read` | View Apollo deals |
| `opportunity_update` | Update Apollo deals |
| `emailer_campaigns_search` | Search Apollo sequences |
| `emailer_campaigns_add_contact_ids` | Add contacts to Apollo sequences |
| `tasks_create` | Create Apollo tasks |
| `tasks_list` | Search Apollo tasks |
| `email_accounts_list` | View Apollo email accounts |

## Use the block [#usage-instructions]

Search for people and companies, enrich contact data, manage your CRM contacts and accounts, add contacts to sequences, and create tasks.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ GitHub workflow blocks and knowledge-base token connections continue to use pers
| Help Scout | `HELPSCOUT_CLIENT_ID` / `HELPSCOUT_CLIENT_SECRET` | `helpscout` |
| HubSpot | `HUBSPOT_CLIENT_ID` / `HUBSPOT_CLIENT_SECRET` | `hubspot` |
| Salesforce | `SALESFORCE_CLIENT_ID` / `SALESFORCE_CLIENT_SECRET` | `salesforce` |
| Apollo | `APOLLO_CLIENT_ID` / `APOLLO_CLIENT_SECRET` | `apollo` |
| Pipedrive | `PIPEDRIVE_CLIENT_ID` / `PIPEDRIVE_CLIENT_SECRET` | `pipedrive` |
| Attio | `ATTIO_CLIENT_ID` / `ATTIO_CLIENT_SECRET` | `attio` |
| Zoho Desk | `ZOHO_CLIENT_ID` / `ZOHO_CLIENT_SECRET` | `zoho-desk` |
Expand Down
1 change: 1 addition & 0 deletions apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -18513,6 +18513,7 @@
"ramp",
"helpscout",
"hubspot",
"apollo",
"linkedin",
"instagram",
"salesforce",
Expand Down
1 change: 1 addition & 0 deletions apps/sim/blocks/blocks/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1600,5 +1600,6 @@ export const ApolloV2Block: BlockConfig = {
type: 'oauth-input',
serviceId: 'apollo',
title: 'Apollo Credential',
credentialKind: 'any',

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.

P2 OAuth offered without setup

If APOLLO_CLIENT_ID or APOLLO_CLIENT_SECRET is missing, changing this picker to credentialKind: 'any' still shows “Connect Apollo account.” The merged picker always includes that action, but the server requires a configured client before starting OAuth. Self-hosted users can open and submit a connection that cannot work.

Hide the OAuth action unless the client is ready, while keeping saved credentials and API-key setup available.

Knowledge Base Used: Integrations, connectors, and tools

}),
}
3 changes: 2 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface SavedCredentialPicker {
type: 'oauth-input'
serviceId: SavedCredentialService
title: string
credentialKind?: 'service-account' | 'any'
}

interface SavedCredentialOptions {
Expand Down Expand Up @@ -160,7 +161,7 @@ export function withSavedCredential(
0,
{
...picker,
credentialKind: 'service-account',
credentialKind: picker.credentialKind ?? 'service-account',
canonicalParamId: 'oauthCredential',
mode: 'basic',
// 'AWS Credential' → 'Select AWS credential': sentence case, brand casing kept.
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/api/contracts/v2/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export const V2_OAUTH_CONNECTION_PROVIDER_IDS = [
'ramp',
'helpscout',
'hubspot',
'apollo',
'linkedin',
'instagram',
'salesforce',
Expand Down
9 changes: 9 additions & 0 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger, setRequestAuth } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { isRecordLike } from '@sim/utils/object'
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
import {
APIError,
Expand Down Expand Up @@ -159,6 +160,7 @@ import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import { formatApolloAuthorizationUrl } from '@/lib/oauth/apollo'
import { clearRecordedRevocation } from '@/lib/oauth/credential-service'
import {
getMicrosoftRefreshTokenExpiry,
Expand Down Expand Up @@ -1242,6 +1244,13 @@ export const auth = betterAuth({
}
}),
after: createAuthMiddleware(async (ctx) => {
if (ctx.path === '/oauth2/link' && ctx.body?.providerId === 'apollo') {
const returned = ctx.context.returned
if (isRecordLike(returned) && typeof returned.url === 'string') {
ctx.context.returned = { ...returned, url: formatApolloAuthorizationUrl(returned.url) }
}
}

if (isBillingEnabled) await recordCustomerRestoreAfterHook(ctx)

const recordFinalAuthenticationResult = async (
Expand Down
8 changes: 8 additions & 0 deletions apps/sim/lib/auth/connectors/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { createApolloProvider } from '@/lib/oauth/apollo'
import { exchangeAtlassianAuthorizationCode } from '@/lib/oauth/atlassian-token'
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
import { createGitHubRepositoriesProvider } from '@/lib/oauth/github-repositories'
Expand Down Expand Up @@ -1042,6 +1043,13 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
},
},

createApolloProvider({
clientId: env.APOLLO_CLIENT_ID as string,
clientSecret: env.APOLLO_CLIENT_SECRET as string,
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/apollo`,
scopes: getCanonicalScopesForProvider('apollo'),
}),

{
providerId: 'pipedrive',
clientId: env.PIPEDRIVE_CLIENT_ID as string,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/core/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,8 @@ export const env = createEnv({
ZOHO_CLIENT_SECRET: z.string().optional(), // Zoho OAuth client secret (Zoho Desk)
WEALTHBOX_CLIENT_ID: z.string().optional(), // WealthBox OAuth client ID
WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret
APOLLO_CLIENT_ID: z.string().optional(),
APOLLO_CLIENT_SECRET: z.string().optional(),
PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID
PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret
LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID
Expand Down
100 changes: 100 additions & 0 deletions apps/sim/lib/oauth/apollo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import { createApolloProvider, formatApolloAuthorizationUrl } from '@/lib/oauth/apollo'

const connector = createApolloProvider({
clientId: 'fixture-client',
clientSecret: 'fixture-secret',
redirectURI: 'https://example.invalid/api/auth/oauth2/callback/apollo',
scopes: ['read_user_profile'],
})

describe('Apollo OAuth protocol', () => {
it('preserves state and callback encoding inside Apollo’s authorization fragment', () => {
const callback = 'https://example.invalid/api/auth/oauth2/callback/apollo'
const state = 'fixture+state/with=padding'
const generated = new URL('https://app.apollo.io/#/oauth/authorize')
generated.searchParams.set('client_id', 'fixture-client')
generated.searchParams.set('redirect_uri', callback)
generated.searchParams.set('state', state)
generated.searchParams.set('scope', 'read_user_profile contacts_search')
generated.searchParams.set('response_type', 'code')

const authorization = new URL(formatApolloAuthorizationUrl(generated.toString()))
expect(authorization.origin).toBe('https://app.apollo.io')
expect(authorization.search).toBe('')
const [path, query] = authorization.hash.slice(1).split('?')
expect(path).toBe('/oauth/authorize')
const params = new URLSearchParams(query)
expect(params.get('state')).toBe(state)
expect(params.get('redirect_uri')).toBe(callback)
expect(params.get('scope')).toBe('read_user_profile contacts_search')
expect(params.get('client_id')).toBe('fixture-client')
})

it('refuses an unexpected authorization destination', () => {
expect(() =>
formatApolloAuthorizationUrl('https://example.invalid/?state=fixture#/oauth/authorize')
).toThrow('Invalid Apollo authorization URL')
})

it('uses the authenticated profile without asserting that Apollo verified its email', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
Response.json({
id: 'fixture-user',
first_name: 'Example',
last_name: 'User',
email: 'fixture@example.invalid',
})
)
)
if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup')
const profile = await connector.getUserInfo({ accessToken: 'fixture-access' })
expect(profile).toMatchObject({
name: 'Example User',
email: 'fixture@example.invalid',
emailVerified: false,
})
expect(profile?.id).toMatch(/^fixture-user-/)
})

it.each([
{ email: 'fixture@example.invalid' },
{ id: '', email: 'fixture@example.invalid' },
{ id: 'fixture-user' },
])('refuses a profile without a usable provider identity and email', async (payload) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(payload)))
if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup')
await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow(
'Invalid Apollo user profile'
)
})

it('does not expose an upstream error body containing a credential', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ error: 'fixture-access' }, { status: 401 }))
)
if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup')
await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow(
'Apollo user profile request failed with HTTP 401'
)
})

it.each([
() => new Response('{"secret":"fixture-access", malformed json'),
() =>
Response.json({
id: 'fixture-user',
email: 'fixture@example.invalid',
extra: 'x'.repeat(65_537),
}),
])('refuses malformed or oversized profiles without echoing their bodies', async (response) => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response()))
if (!connector.getUserInfo) throw new Error('Apollo needs a profile lookup')
await expect(connector.getUserInfo({ accessToken: 'fixture-access' })).rejects.toThrow(
'Invalid Apollo user profile'
)
})
})
72 changes: 72 additions & 0 deletions apps/sim/lib/oauth/apollo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { generateId } from '@sim/utils/id'
import type { GenericOAuthConfig } from 'better-auth/plugins'
import { z } from 'zod'
import { readResponseJsonWithLimit } from '@/lib/core/utils/stream-limits'

export const APOLLO_TOKEN_URL = 'https://app.apollo.io/api/v1/oauth/token'

const profileSchema = z.object({
id: z.string().trim().min(1),
email: z.email(),
first_name: z.string().nullish(),
last_name: z.string().nullish(),
})

/** Moves Better Auth's query into the fragment Apollo's client-side authorize page reads. */
export function formatApolloAuthorizationUrl(value: string): string {
const url = new URL(value)
if (
url.origin !== 'https://app.apollo.io' ||
url.pathname !== '/' ||
url.hash !== '#/oauth/authorize'
) {
throw new Error('Invalid Apollo authorization URL')
}
const query = url.search
url.search = ''
url.hash = `/oauth/authorize${query}`
return url.toString()
}

/** Connects an Apollo partner app using its documented OAuth and current-user profile endpoints. */
export function createApolloProvider(
config: Pick<GenericOAuthConfig, 'clientId' | 'clientSecret' | 'redirectURI' | 'scopes'>
): GenericOAuthConfig {
return {
...config,
providerId: 'apollo',
authorizationUrl: 'https://app.apollo.io/#/oauth/authorize',
tokenUrl: APOLLO_TOKEN_URL,
authentication: 'post',
getUserInfo: async (tokens) => {
// boundary-raw-fetch: Apollo's external current-user endpoint verifies the OAuth token's identity.
const response = await fetch('https://app.apollo.io/api/v1/users/api_profile', {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
redirect: 'error',
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) {
await response.body?.cancel()
throw new Error(`Apollo user profile request failed with HTTP ${response.status}`)
}
let payload: unknown
try {
payload = await readResponseJsonWithLimit(response, {
maxBytes: 64 * 1024,
label: 'Apollo user profile',
})
} catch {
throw new Error('Invalid Apollo user profile')
}
const parsed = profileSchema.safeParse(payload)
if (!parsed.success) throw new Error('Invalid Apollo user profile')
const profile = parsed.data
return {
id: `${profile.id}-${generateId()}`,
name: [profile.first_name, profile.last_name].filter(Boolean).join(' ') || profile.email,
email: profile.email,
emailVerified: false,
}
},
}
}
45 changes: 43 additions & 2 deletions apps/sim/lib/oauth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseTextWithLimit,
} from '@/lib/core/utils/stream-limits'
import { APOLLO_TOKEN_URL } from '@/lib/oauth/apollo'
import { getDocusignOAuthUrl } from '@/lib/oauth/docusign'
import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types'
import {
Expand Down Expand Up @@ -1993,8 +1994,34 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
serviceAccountProviderId: 'apollo-service-account',
icon: ApolloIcon,
baseProviderIcon: ApolloIcon,
scopes: [],
authType: 'service_account',
scopes: [
'read_user_profile',
'mixed_people_api_search',
'people_match',
'people_bulk_match',
'mixed_companies_search',
'organizations_enrich',
'organizations_bulk_enrich',
'contact_write',
'contact_update',
'contacts_search',
'contacts_bulk_create',
'contacts_bulk_update',
'account_write',
'account_update',
'accounts_search',
'account_bulk_create',
'account_stages_update',
'opportunity_write',
'opportunities_list',
'opportunity_read',
'opportunity_update',
'emailer_campaigns_search',
'emailer_campaigns_add_contact_ids',
'tasks_create',
'tasks_list',
'email_accounts_list',
],
},
},
defaultService: 'apollo',
Expand Down Expand Up @@ -4246,6 +4273,20 @@ function getProviderAuthConfig(
supportsRefreshTokenRotation: true,
}
}
case 'apollo': {
const { clientId, clientSecret } = getConfiguredClientCredentials(
'apollo',
'APOLLO_CLIENT_ID',
'APOLLO_CLIENT_SECRET'
)
return {
tokenEndpoint: APOLLO_TOKEN_URL,
clientId,
clientSecret,
useBasicAuth: false,
supportsRefreshTokenRotation: true,
}
}
case 'pipedrive': {
const { clientId, clientSecret } = getConfiguredClientCredentials(
'pipedrive',
Expand Down
Loading
Loading