Apify Actor with per-user OAuth via Scalekit
Build an Apify Actor that uses Scalekit Agent Auth so each user connects their OAuth accounts, keyed by Apify userId.
An Apify Actor is a stateless serverless container. Every run starts cold — no session, no cookies, no “current user.” When you want each person who runs your Actor to access their own Notion workspace, their own Gmail, or their own GitHub account, you need to map Apify’s identity model onto an OAuth token store that persists across runs.
Scalekit solves this with a connected-accounts model: for each (connector, identifier) pair, it stores one OAuth session and refreshes it automatically. This recipe builds an Actor that connects to Notion per-user and to YouTube via a shared account, using Apify’s native userId as the per-user identifier. It also shows how to surface the OAuth consent step as a live interactive page inside the Actor run, instead of a raw link buried in JSON output.
What this recipe covers:
- Per-user identity without input fields — derive the connected-account identifier from
Actor.getEnv().userIdso users never type an email or ID - Shared vs per-user connectors — hardcode a single identifier for connectors shared across all users; derive one per user for private accounts
- Interactive auth UX — serve a branded OAuth consent page on Apify’s live-view port so users click a button rather than hunting for a raw URL
- Input schema design — expose only the
taskfield to end users; keep all auth and config internal
The complete source is available in the notion-youtube-agent repository.
Before you start
Section titled “Before you start”You need working OAuth credentials for each third-party API your Actor will connect to. Scalekit manages the token lifecycle, but the underlying API must be enabled and the OAuth client must exist first.
For YouTube (or any Google API):
- Open the Google Cloud Console and select your project.
- Go to APIs & Services → Enabled APIs & services and enable YouTube Data API v3. Without this, every tool call returns
permission_deniedeven if the OAuth token is valid. - Go to APIs & Services → OAuth consent screen and add the Google accounts that will authorize the Actor under Test users. While the app is in “Testing” publishing status, only accounts listed here can complete the OAuth flow — all others see
Error 403: org_internal. - Create an OAuth 2.0 client (APIs & Services → Credentials → + Create credentials → OAuth client ID). Set the application type to Web application. Leave the redirect URI blank for now — you’ll add it from Scalekit in the next section.
For Notion:
- Go to notion.so/my-integrations and create a new integration, or use Notion’s OAuth setup if your Actor uses Scalekit’s Notion OAuth connector.
For both connectors:
- A Scalekit environment with API credentials (
SCALEKIT_ENV_URL,SCALEKIT_CLIENT_ID,SCALEKIT_CLIENT_SECRET). - Apify CLI installed:
npm install -g @apify/cli - Node.js 18+
1. Set up connections in Scalekit
Section titled “1. Set up connections in Scalekit”In the Scalekit Dashboard, go to AgentKit → Connections and create two connections:
YouTube connection (shared)
- Search for YouTube and click Create.
- Copy the Redirect URI from the connection panel (it looks like
https://<ENV_URL>/sso/v1/oauth/<CONNECTION_ID>/callback). - Paste it into your Google Cloud OAuth client under Authorized redirect URIs and save.
- Back in Scalekit, enter the Client ID and Client Secret from your Google Cloud OAuth client.
- Under Scopes, select at least
youtube.readonly. Addyoutubeif your Actor needs write access (playlists, subscriptions). Addyt-analytics.readonlyif you query analytics data. - Click Save. Note the Connection name (e.g.,
youtube) — your code must match it exactly.
Notion connection (per-user)
- Search for Notion and click Create.
- Enter the Client ID and Client Secret from your Notion integration or OAuth app.
- Scalekit pre-configures the redirect URI and scopes for Notion. Click Save.
- Note the Connection name (e.g.,
notion).
2. Create the Apify Actor project
Section titled “2. Create the Apify Actor project”apify create notion-youtube-agent -t project_emptycd notion-youtube-agentnpm install @scalekit-sdk/node openai apifySet your Scalekit credentials as Actor environment variables in the Apify Console under Settings → Environment variables:
SCALEKIT_ENV_URL=https://your-env.scalekit.devSCALEKIT_CLIENT_ID=skc_...SCALEKIT_CLIENT_SECRET=your-secretIf your Actor creates new Notion pages (not just writing to existing ones), also set a default parent location. Without this, the Actor can only write to pages that already exist by exact title match.
NOTION_DEFAULT_PARENT_PAGE_ID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6dTo find a Notion page ID: open the page in Notion, click Share → Copy link. The 32-character hex string at the end of the URL is the page ID.
3. Derive user identity from Apify’s runtime
Section titled “3. Derive user identity from Apify’s runtime”Apify exposes the identity of the account running the Actor through Actor.getEnv(). Use userId directly as the Scalekit connected-account identifier — no email field, no manual input.
import { Actor } from 'apify';
await Actor.init();
const { userId } = Actor.getEnv();
// userId is stable per Apify account — the same user always gets the same token.const notionIdentifier = userId;userId is a stable opaque string that Apify sets for the account running the Actor. It persists across runs, so the first run that completes OAuth will find an active token on every subsequent run.
4. Choose shared vs per-user identifiers
Section titled “4. Choose shared vs per-user identifiers”Not every connector needs per-user isolation. A YouTube data connection used for research can be shared across all Actor runs with a hardcoded identifier. Only connectors that access private user data need per-user identifiers.
// Per-user: each Apify account connects their own Notion workspace.const notionIdentifier = userId;
// Shared: one YouTube OAuth session used by all runs.const youtubeIdentifier = 'shared-youtube';Hardcode the shared identifier in code — do not expose it as an input field. End users should not need to know it exists.
5. Ensure each connector is authorized
Section titled “5. Ensure each connector is authorized”Before calling any API, check whether the connected account is active. If not, generate a magic link and wait for the user to complete the OAuth flow.
import { Actor } from 'apify';
const ACTIVE = 1;
export async function ensureNotionConnected(scalekitActions, identifier, { pollIntervalMs = 5_000, timeoutMs = 300_000, onMagicLink = async () => {},} = {}) { const resp = await scalekitActions.getOrCreateConnectedAccount({ connectionName: 'notion', identifier, }); const account = resp.connectedAccount ?? resp;
if (account.status === ACTIVE) { return account.id; }
const { link } = await scalekitActions.getAuthorizationLink({ connectionName: 'notion', identifier, });
const markDone = await onMagicLink(link);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) { await sleep(pollIntervalMs);
const pollResp = await scalekitActions.getOrCreateConnectedAccount({ connectionName: 'notion', identifier, }); const polled = pollResp.connectedAccount ?? pollResp;
if (polled.status === ACTIVE) { markDone?.(); await Actor.setStatusMessage('Notion authorized — proceeding.'); return polled.id; } }
throw new Error(`Timed out waiting for Notion authorization.`);}
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms));}The same pattern applies to every connector. Copy the function, change connectionName, and pass in the appropriate identifier.
6. Surface auth as a live interactive page
Section titled “6. Surface auth as a live interactive page”Printing a raw magic link to the console or burying it in JSON output creates a poor experience. Apify Actors can start an HTTP server on ACTOR_WEB_SERVER_PORT (default 4321), and Apify automatically exposes it as a public URL while the run is active. Use this to serve a branded OAuth consent page.
import http from 'http';import { Actor } from 'apify';
const PORT = parseInt(process.env.ACTOR_WEB_SERVER_PORT ?? '4321', 10);
let server = null;
export function getLiveViewUrl() { const { actorId, actorRunId } = Actor.getEnv(); return `https://${actorId}--${actorRunId}-${PORT}.runs.apify.net`;}
export async function serveAuthPage(link, serviceName) { let html = buildAuthPage(link, serviceName);
if (server) server.close(); server = http.createServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); }); server.listen(PORT);
return { liveViewUrl: getLiveViewUrl(), markDone: () => { html = buildDonePage(serviceName); }, };}
function buildAuthPage(link, serviceName) { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8" /> <title>Authorize ${serviceName}</title> <style> body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; } .card { background: white; padding: 2rem; border-radius: 12px; text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,0.08); max-width: 480px; width: 100%; } a.btn { display: inline-block; background: #0a6b50; color: white; padding: 0.75rem 1.5rem; border-radius: 8px; text-decoration: none; font-weight: 600; } </style></head><body> <div class="card"> <h1>🔐 Connect ${serviceName}</h1> <p>Click below to authorize access to your ${serviceName} account. The actor will continue automatically once you complete authorization.</p> <a class="btn" href="${link}" target="_blank" rel="noopener">Authorize ${serviceName} →</a> </div></body></html>`;}
function buildDonePage(serviceName) { return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><title>${serviceName} Authorized</title></head><body style="font-family:system-ui;text-align:center;padding:3rem"> <h1>✅ ${serviceName} Authorized</h1> <p>Returning to task — you can close this tab.</p></body></html>`;}The live view URL follows this pattern:
https://{actorId}--{actorRunId}-{PORT}.runs.apify.netBoth actorId and actorRunId come from Actor.getEnv() — the same call that gives you userId.
7. Wire auth into the Actor entry point
Section titled “7. Wire auth into the Actor entry point”Pass the live view callback into ensureNotionConnected. The callback starts the HTTP server, stores the markDone function, and returns it so the polling loop can update the page when auth completes.
import { Actor } from 'apify';import { ScalekitClient } from '@scalekit-sdk/node';import { ensureNotionConnected } from './notionAuth.js';import { serveAuthPage } from './authServer.js';
await Actor.init();
const input = await Actor.getInput();const { task, llmApiKey } = input;
const { userId } = Actor.getEnv();const notionIdentifier = userId;const youtubeIdentifier = 'shared-youtube';
const scalekit = new ScalekitClient( process.env.SCALEKIT_ENV_URL, process.env.SCALEKIT_CLIENT_ID, process.env.SCALEKIT_CLIENT_SECRET,);
await ensureNotionConnected(scalekit.actions, notionIdentifier, { onMagicLink: async (link) => { const { liveViewUrl, markDone } = await serveAuthPage(link, 'Notion');
// Store the live view URL in OUTPUT so the Apify UI shows a clickable link. await Actor.setValue('OUTPUT', { status: 'AWAITING_NOTION_AUTH', authPageUrl: liveViewUrl, message: 'Open authPageUrl in your browser to authorize Notion.', });
await Actor.setStatusMessage(`ACTION REQUIRED: Authorize Notion → ${liveViewUrl}`);
return markDone; },});
// ... run the agent, push resultsEnd-user experience after this change:
- User starts the Actor run and types their task
- Output panel immediately shows a clickable
authPageUrl - User opens the URL and sees a branded “Authorize Notion →” button
- After completing OAuth, the page updates to ”✅ Notion Authorized”
- The Actor continues automatically — no re-run needed
8. Design the input schema for end users
Section titled “8. Design the input schema for end users”The Actor’s input form should show only what the end user actually needs to provide. All auth identifiers, LLM config, and internal settings stay out of the form.
{ "title": "Notion + YouTube AI Agent", "type": "object", "schemaVersion": 1, "properties": { "task": { "title": "Task", "type": "string", "description": "Natural language task for the agent. Examples: 'List the 5 most recently edited pages in my Notion workspace' or 'Search YouTube for React tutorial channels and append the top 10 to my Research page'.", "editor": "textarea" }, "llmApiKey": { "title": "LLM API Key", "type": "string", "description": "API key for the LLM endpoint.", "isSecret": true, "editor": "textfield" } }, "required": ["task", "llmApiKey"]}Everything else — notionIdentifier, youtubeIdentifier, timeouts, model name, base URL — is either derived at runtime (userId) or hardcoded and deployed as an Actor environment variable.
9. Testing
Section titled “9. Testing”Run locally:
apify runProvide input in storage/key_value_stores/default/INPUT.json:
{ "task": "List the 5 most recently edited pages in my Notion workspace", "llmApiKey": "sk-..."}Because Actor.getEnv().userId is undefined locally, the notionIdentifier falls back to your local development value. After you confirm the flow works, deploy to Apify:
apify pushOn the first cloud run, the Actor outputs an authPageUrl. Open it, click Authorize Notion, and complete the OAuth flow. The Actor polls and continues automatically. On every subsequent run for the same Apify account, the token is already active and the auth step is skipped entirely.
Common mistakes
Section titled “Common mistakes”Tool calls fail with permission_denied even though the account is ACTIVE
- Symptom:
[permission_denied] tool execution failed - forbidden accessin logs. The connected account status is ACTIVE and authorization completed successfully. - Cause: The underlying API is not enabled in the cloud provider console. An ACTIVE connected account means the OAuth token exists — it does not mean the API accepts calls. For YouTube, this happens when YouTube Data API v3 is not enabled in the Google Cloud project.
- Fix: Go to Google Cloud Console → APIs & Services → Enabled APIs and enable YouTube Data API v3. No code change or re-authorization needed — existing tokens work once the API is enabled.
Authorization fails with Error 403: org_internal
- Symptom: Google shows “Access blocked: [App name] can only be used within its organization” when a user tries to authorize.
- Cause: The Google account attempting to authorize is not listed as a test user. While the OAuth app is in “Testing” publishing status, only accounts explicitly added as test users can complete the OAuth flow.
- Fix: Go to Google Cloud Console → APIs & Services → OAuth consent screen and add the Google account under Test users. No need to change the app’s publishing status or user type — just add the account and retry.
Tool calls fail with permission_denied after adding scopes
- Symptom: Same
permission_deniederror. The connection has the right scopes configured, but you added them after the user already authorized. - Cause: OAuth tokens carry the scopes that were configured at authorization time. Adding scopes to a connection does not retroactively update existing tokens.
- Fix: Delete the connected account in the Scalekit dashboard (or via API) and have the user re-authorize. The new token will include the updated scopes.
Notion page creation fails with “no parent_page_id/database_id provided”
- Symptom: The agent finds no page with the requested title and throws
Notion page "X" was not found and cannot be created because no parent_page_id/database_id was provided. - Cause: Notion’s API requires a parent location for every new page. The Actor checks for a default parent in the input, then in environment variables, and throws if neither is set.
- Fix: Set
NOTION_DEFAULT_PARENT_PAGE_IDorNOTION_DEFAULT_DATABASE_IDas an Actor environment variable. To find a page ID, open the page in Notion, click Share → Copy link, and extract the 32-character hex string from the URL.
Using email as the identifier
- Symptom: Input form asks for user email, or the identifier is passed in as an input field
- Cause: Treating the connected-account identifier as a user-facing concept
- Fix: Use
Actor.getEnv().userIdas the identifier. It is stable, unique per Apify account, and requires no input from the user. Scalekit does not require an email — it accepts any unique string as an identifier.
placeholder property causes build failure
- Symptom:
apify pushfails withProperty schema.properties.task.placeholder is not allowed. - Cause:
placeholderis not part of the Apify input schema specification - Fix: Move example text into the
descriptionfield. It appears as helper text in the Apify Console input form.
userId is undefined locally
- Symptom: Actor crashes with
Could not determine Apify user IDduringapify run - Cause:
Actor.getEnv()does not populateuserIdin local runs - Fix: Fall back to a local dev value:
const notionIdentifier = userId ?? 'local-dev-user'
Stale variable name causes ReferenceError at runtime
- Symptom: Actor fails with
notionUserEmail is not definedeven though you removed that field - Cause: The variable was renamed in some places but left in others — console logs, OUTPUT payloads, or
runAgentarguments - Fix: Search the entire codebase for the old variable name before deploying. One missed reference fails at runtime, not at build time.
Live view URL not available in local runs
- Symptom:
getLiveViewUrl()returns a broken URL duringapify run - Cause:
actorIdandactorRunIdare alsoundefinedlocally - Fix: Guard the live view server behind a check:
if (actorId && actorRunId) { ... }. Fall back to logging the raw magic link to the console for local development.
Production notes
Section titled “Production notes”Token persistence across runs — Scalekit stores the OAuth token server-side keyed by (connectionName, identifier). As long as userId is stable (it is), the user only completes the OAuth flow once. Subsequent runs call getOrCreateConnectedAccount and get an active account back immediately.
Token refresh — Scalekit refreshes expired tokens automatically before returning them. You do not need to track expiry or call a refresh endpoint.
Re-authorization — If a user revokes access in Notion’s settings, getOrCreateConnectedAccount returns a non-active account. The Actor generates a new magic link automatically. No code change required — the polling loop handles it the same way as a first-time auth.
Shared connectors — The shared-youtube identifier works because YouTube access is the same for all users (e.g., read-only public data). Any connector where all users share the same OAuth session can use a hardcoded identifier. Private data connectors — Notion, Gmail, GitHub — should always use a per-user identifier.
Input schema changes require a redeploy — The Apify Console reads the input schema from the deployed build. Changes to .actor/input_schema.json only take effect after apify push.
Next steps
Section titled “Next steps”- Add more per-user connectors — The same
ensureConnected+onMagicLinkpattern works for any Scalekit connector. Add asrc/githubAuth.jsfollowing the same structure asnotionAuth.js. - Use built-in actions — For connectors with Scalekit built-in tools, replace manual API calls with
scalekit.actions.executeTool. See all supported connectors. - Extend the input schema — Add optional fields like
maxIterationsorllmModelwith defaults, so power users can tune the Actor without the defaults getting in the way for casual users. - Review the agent auth quickstart — For a broader overview of the connected-accounts model, see the agent auth quickstart.